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/CLAUDE.md b/CLAUDE.md index ed3c414..17af5e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,9 @@ This is a Bun workspace of publishable packages under `packages/*`: group membership). - `packages/platform` — `@misofm/platform`, everything Miso builds on top of the object models (work and Party extensions, royalty primitives, Vault, Actions, product workflows). +- `packages/effect` — `@misofm/effect`, the shared Effect v4 foundation (one `SuiClient` + service, the tagged error vocabulary, read/execute primitives). Every other package + depends on it. - `packages/streaming`, `packages/transcoding` — the streaming transcode contract and transcoder. @@ -63,9 +66,21 @@ intrinsic to a Party (identity, admin cap, membership), it belongs in `partyos`; opinion Miso's product adds on top — including every `party_*` extension — it belongs in `platform`. +## Effect conventions + +- Effect v4 release candidate, pinned exactly (see any package's `peerDependencies`). Read + `node_modules/effect/AGENTS.md` and `ai-docs/` before writing Effect code; v4 is not v3. +- I/O returns `Effect` with `SuiClient` (from `@misofm/effect`) in the requirements; client + extension classes provide the layer internally. PTB builders stay synchronous. +- Failures are `Schema.TaggedError` classes in each package's `src/errors.ts` (`./errors` + subpath); never throw strings or plain `Error` from public reads. +- Domain types are `Schema.Class`; generated BCS shapes under `src/contracts/` never appear + in a public signature. + ## Project Rules - Use `@mysten/sui` v2 APIs and gRPC/Core client patterns; do not add JSON-RPC. - Do not hand-edit generated files under any `packages/*/src/contracts/`. - Keep PTB helpers composable: accept a `Transaction`, return results when useful, and do not execute. -- Run `bun run typecheck`, `bun test`, `bun run build`, and `bun run codegen:check` before handoff. +- Run `bun run typecheck`, `bun test`, `bun run build`, `bun run codegen:check`, and + `bun run test:consumer` before handoff. diff --git a/README.md b/README.md index 075f61e..473d319 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Monorepo for the Miso platform-layer TypeScript SDKs. | Package | Description | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| [`packages/effect`](packages/effect/README.md) | `@misofm/effect` — the shared Effect v4 foundation: the `SuiClient` service, the tagged error vocabulary, and the object-read and execute primitives every other SDK is built on. | | [`packages/musicos`](packages/musicos/README.md) | `@misofm/musicos` — typed bindings, queries, event decoders, and PTB builders for the works object model (Composition, Recording, Release, Track). | | [`packages/partyos`](packages/partyos/README.md) | `@misofm/partyos` — typed bindings, queries, and PTB builders for the Party object model (Party, admin cap, group membership). | | [`packages/platform`](packages/platform/README.md) | `@misofm/platform` — the complete client SDK for the Miso platform layer: work and Party extensions, catalog/artist/wallet reads, record production and sale, and Vault custody/Actions/plugins. | @@ -33,6 +34,16 @@ everything else. `sui-codegen.config.ts` is the source of truth for exactly which Move package lands in which tree — see its `packages` list rather than looking for an enumeration here. +## Effect + +Every SDK exposes [Effect](https://effect.website) v4 in its public API: reads and +submissions return `Effect` values that require the `SuiClient` service from +`@misofm/effect` and fail with tagged errors (`Effect.catchTag`); the client +extensions (`miso()`, `partyos()`, `misoPlatform()`) provide that service +themselves so their methods have no requirements; PTB builders stay synchronous. +Each package's `./errors` subpath lists its failures. Pin `effect` to the exact +release candidate the packages peer on. + ## Development This is a Bun workspace. diff --git a/bun.lock b/bun.lock index 7ddc70d..4c2a8e1 100644 --- a/bun.lock +++ b/bun.lock @@ -11,72 +11,100 @@ "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", + "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", }, }, "packages/platform": { "name": "@misofm/platform", - "version": "0.25.0", + "version": "0.26.0", "dependencies": { - "@misofm/musicos": "0.2.0", - "@misofm/partyos": "0.2.0", - "@misofm/streaming": "0.2.1", + "@misofm/effect": "0.1.0", + "@misofm/musicos": "0.3.0", + "@misofm/partyos": "0.3.0", + "@misofm/streaming": "0.3.0", "@mysten/bcs": "^2.1.1", }, "devDependencies": { "@mysten/move-bytecode-template": "^0.4.0", "@mysten/sui": "2.29.0", "@types/bun": "^1.3.14", + "effect": "4.0.0-rc.112", "typescript": "^7.0.2", }, "peerDependencies": { "@mysten/move-bytecode-template": "^0.4.0", "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", "typescript": "^5.7 || ^6 || ^7", }, }, "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": [ @@ -85,9 +113,9 @@ }, "packages/transcoding": { "name": "@misofm/transcoding", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { - "@misofm/streaming": "0.2.1", + "@misofm/streaming": "0.3.0", "m3u8-parser": "7.2.0", }, "devDependencies": { @@ -125,6 +153,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" +} 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..9eaff47 100644 --- a/packages/musicos/src/client.ts +++ b/packages/musicos/src/client.ts @@ -1,11 +1,21 @@ // Copyright (c) Miso Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +import type { ConflictingWorkKindError } from "./errors.ts"; import type { ClientWithCoreApi, 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 +36,7 @@ import type { RecordingAdminCap, Release, ReleaseAdminCap, + ReleaseRegistry, } from "./types.ts"; // Generated call modules (type-safe Move calls) and BCS structs. @@ -66,7 +77,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 +154,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 +232,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 +315,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..a54af80 --- /dev/null +++ b/packages/musicos/src/errors.ts @@ -0,0 +1,30 @@ +// 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. +import { Schema } from "effect"; + +export { + ObjectNotFoundError, + ObjectTypeMismatchError, + SuiRpcError, + BcsDecodeError, + TransactionFailedError, + GraphQLUnavailableError, + 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/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..5808222 100644 --- a/packages/musicos/src/queries.ts +++ b/packages/musicos/src/queries.ts @@ -6,22 +6,31 @@ // 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 { ConflictingWorkKindError } from "./errors.ts"; +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 +38,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 +84,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 +172,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 +201,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 +225,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 +263,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 +338,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 +359,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 +371,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 +396,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 +418,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 +461,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}`, - ); + return yield* new ConflictingWorkKindError({ objectId: normalized, kinds: [previous, kind] }); } kinds.set(normalized, kind); } @@ -481,93 +479,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 +576,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 +598,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 +624,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 +660,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 +673,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 +706,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 +796,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 +814,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 +857,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/platform/README.md b/packages/platform/README.md index 9ca8535..23eb27a 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -98,7 +98,14 @@ singletons. Register the client extension on any client implementing Sui's Core API ([SDK building guidelines](https://sdk.mystenlabs.com/sui/sdk-building)): +Every method that performs I/O — a chain read, a signed execution — returns an +`Effect.Effect` (`R = never`: the client already provided `SuiClient` +internally). Run a program with `Effect.runPromise` (or compose it further +with other Effects) at your own boundary; PTB builders (`tx.*`) stay plain +synchronous functions. + ```ts +import { Effect } from "effect"; import { SuiGrpcClient } from "@mysten/sui/grpc"; import { Transaction } from "@mysten/sui/transactions"; import { miso } from "@misofm/platform"; @@ -107,40 +114,58 @@ const client = new SuiGrpcClient({ network: "testnet", baseUrl }).$extend( miso({ deployment: verifiedDeployment }), ); -// One memoized Core API read proves this endpoint is the deployment's exact -// ledger before any synchronous client-bound builder can be used. -await client.miso.ready(); - -// The permissionless object-model SDK is part of the same facade. -const release = await client.miso.protocol.getReleaseById(releaseId); -// 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 -// `partyos` (core) and `party` (extensions) sections — also importable -// standalone from `@misofm/platform/party`. -const party = await client.miso.party.getPartyById(partyId); - -// Read: run + one currency's offer, one round trip, no registry lookup. -const { pressing, listing } = await client.miso.getSale({ - releaseId, - edition, - currencyType: USD_COIN_TYPE, +const program = Effect.gen(function* () { + // One memoized Core API read proves this endpoint is the deployment's exact + // ledger before any synchronous client-bound builder can be used. + yield* client.miso.ready(); + + // The permissionless object-model SDK is part of the same facade. + 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 + // `partyos` (core) and `party` (extensions) sections — also importable + // standalone from `@misofm/platform/party`. + const party = yield* client.miso.party.getPartyById(partyId); + + // Read: run + one currency's offer, one round trip, no registry lookup. + const { pressing, listing } = yield* client.miso.getSale({ + releaseId, + edition, + currencyType: USD_COIN_TYPE, + }); + + return { release, party, pressing, listing }; }); -// Write: a thunk, so it composes with protocol calls in the same PTB. +const { pressing, listing } = await Effect.runPromise(program); + +// Write: a thunk, so it composes with protocol calls in the same PTB. `tx` +// builders never touch the network — no Effect involved. const tx = new Transaction(); tx.add( client.miso.tx.purchaseRecord({ releaseId, edition, currencyType: USD_COIN_TYPE, - paymentAmount: listing.pricing.amount, - expectedPricing: listing.pricing, + paymentAmount: listing!.pricing.amount, + expectedPricing: listing!.pricing, recipient: buyer, }), ); ``` +A read that can fail closed (a missing release, a mismatched chain identifier) +fails with one of this SDK's tagged errors — see [Errors](#errors) — so +recover with `Effect.catchTag`/`Effect.catchTags` instead of inspecting a +thrown message: + +```ts +const releaseOrNull = client.miso.protocol! + .getReleaseById(releaseId) + .pipe(Effect.catchTag("ObjectNotFoundError", () => Effect.succeed(null))); +``` + The bundled Testnet deployment includes both verified immutable package IDs. Custom deployments can still mark sales unavailable explicitly; sales builders and readers fail closed whenever either package is unavailable. @@ -173,11 +198,16 @@ it. `PartyPlatformClient` wraps a `PartyosClient` — every core method standalone (the same class `client.miso.party` returns): ```ts +import { Effect } from "effect"; import { PartyosClient } from "@misofm/partyos"; import { PartyPlatformClient } from "@misofm/platform/party"; const core = new PartyosClient(client, verifiedDeployment.partyos); const party = new PartyPlatformClient(client, core, verifiedDeployment.party); + +// Every read method returns Effect (R = never) — the class already +// carries the ClientWithCoreApi it was constructed with. +const profile = await Effect.runPromise(party.getProfile(partyId)); // Option ``` Every generated Move package on this side of the boundary — the Party @@ -200,7 +230,14 @@ objects into the JSON-safe views a client actually renders. It works in browsers Workers, and servers. Miso's HTTP API is a thin validated and cached transport over this same surface, not a separate domain implementation. +Every read here takes the resolved `MisoConfig` (package ids, not a transport) +and declares `SuiClient`/`SuiGraphQL` in its Requirements — `createMisoClient` +still bundles the transport, config, and Party client the way it always did; +provide its `sui`/`graphqlRaw` as the Effect services once, at the boundary: + ```ts +import { Effect, Layer } from "effect"; +import { SuiClient, SuiGraphQL } from "@misofm/effect"; import { createMisoClient, getDiscoverShelf, @@ -209,10 +246,16 @@ import { } from "@misofm/platform/read"; const miso = createMisoClient({ config: verifiedReadConfig }); +const layer = Layer.mergeAll(SuiClient.layer(miso.sui), SuiGraphQL.layer(miso.graphqlRaw)); -const discover = await getDiscoverShelf(miso); -const release = await getReleaseDetail(miso, releaseId); -const library = await getOwnedRecords(miso, walletAddress); +const program = Effect.gen(function* () { + const discover = yield* getDiscoverShelf(miso.config); + const release = yield* getReleaseDetail(releaseId, miso.config); + const library = yield* getOwnedRecords(walletAddress, miso.config); + return { discover, release, library }; +}); + +const { discover, release, library } = await Effect.runPromise(program.pipe(Effect.provide(layer))); ``` The package root also exposes the same functions under the `read` namespace: @@ -221,7 +264,9 @@ The package root also exposes the same functions under the `read` namespace: import { read } from "@misofm/platform"; const miso = read.createMisoClient({ config: verifiedReadConfig }); -const artist = await read.getArtistProfile(miso, partyId); +const artist = await Effect.runPromise( + read.getArtistProfile(partyId, miso.config).pipe(Effect.provide(SuiClient.layer(miso.sui))), +); ``` ### Authenticated platform mutations @@ -418,6 +463,7 @@ Recording creator remainder; lower-level routed-stake builders remain available for the Move layer's 100% composition-cut case. ```ts +import { Effect } from "effect"; import { assertAtomicPublicationBounds, parseAtomicPublicationResult, @@ -437,9 +483,8 @@ const publication = { // PTB exceeds the SDK's command/input safety limits or has an invalid graph. assertAtomicPublicationBounds(publication); -const executed = await client.miso.executeViaExecutor( - executor, - publishAtomicCatalog(publication), +const executed = await Effect.runPromise( + client.miso.executeViaExecutor(executor, publishAtomicCatalog(publication)), ); const result = parseAtomicPublicationResult(publication, executed); ``` @@ -466,29 +511,30 @@ embedded as `SHARE_TEMPLATE`, initializer patched via `patchInitializer`). Publish and initialize are necessarily two transactions: ```ts +import { Effect } from "effect"; + // Sequential (one currency, two txs): -const currency = await client.miso.createShareCurrency(signer, { - name: "Song Shares", - description: "…", -}); +const currency = await Effect.runPromise( + client.miso.createShareCurrency(signer, { + name: "Song Shares", + description: "…", + }), +); // → { packageId, currencyId, shareType, treasuryCapId, gasUsed } -// Batched (many currencies, via a ParallelTransactionExecutor): +// Batched (many currencies, via a ParallelTransactionExecutor). Both submit +// exclusively through the executor (R = never), unlike `createShareCurrency` +// above, which needs `SuiClient` for its sequential `execThunks` calls. import { publishShareCurrencies, initializeShareCurrencies } from "@misofm/platform"; -const { packageIds } = await publishShareCurrencies( - executor, - initializerAddress, - 10, -); -const { currencies } = await initializeShareCurrencies( - executor, - signerAddress, - packageIds, - (pkg) => ({ + +const program = Effect.gen(function* () { + const { packageIds } = yield* publishShareCurrencies(executor, initializerAddress, 10); + return yield* initializeShareCurrencies(executor, signerAddress, packageIds, (pkg) => ({ name: "…", description: "…", - }), -); + })); +}); +const { currencies } = await Effect.runPromise(program); ``` `executeViaExecutor(executor, ...thunks)` (`execute.ts`) submits a @@ -670,6 +716,59 @@ carry an optional `RecordingRoleLevel` (`Producer`, `Vocalist`, `Engineer`, (`Instrumentalist`, `Custom`, and the unleveled `ArtistsAndRepertoire` / `Copyist`) are spelled out separately in `RecordingRole`. +## Errors + +Every typed failure is a `Schema.TaggedError` (`@misofm/platform/errors`), +recoverable with `Effect.catchTag`/`Effect.catchTags` instead of message +sniffing. The foundation's Sui read/write vocabulary +(`ObjectNotFoundError { objectId }`, `ObjectTypeMismatchError { objectId, +expected, actual }`, `SuiRpcError { operation, cause }`, `BcsDecodeError { +type, objectId?, cause }`, `TransactionFailedError { digest, status }`, +`GraphQLUnavailableError {}`, `DeploymentError { message }`) is re-exported +from `@misofm/effect/errors` so this is the only import a consumer needs: + +```ts +import { + ObjectNotFoundError, + ReleaseNotFoundError, + RecordSalesUnavailableError, + OperationsUnavailableError, + MisoChainIdentifierMismatchError, + MisoClientNotReadyError, + MisoNetworkMismatchError, +} from "@misofm/platform/errors"; +``` + +Platform-specific tags: + +| Error | Fields | Raised by | +| --- | --- | --- | +| `RecordSalesUnavailableError` | `reason` | `requireRecordSalesDeployment`, every sales read/builder on a legacy or unconfigured deployment | +| `OperationsUnavailableError` | `reason` | `requireOperationsDeployment`, `client.miso.vault`/`call.*` operations surfaces | +| `MisoPlatformDeploymentInvalidError` | `message` | `assertMisoPlatformDeployment` / `normalizeMisoPlatformDeployment` | +| `MisoNetworkMismatchError` | `clientNetwork`, `deploymentNetwork` | `miso()`/`misoPlatform()` registration (synchronous) | +| `MisoChainIdentifierMismatchError` | `actual`, `expected` | `client.miso.ready()` | +| `MisoClientNotReadyError` | `operation` | synchronous client-bound surfaces (`tx`, `ids`, `call`, `vault`, `party`, `protocol`) used before `ready()` | +| `MalformedRecordSoldEventError` | `digest?`, `reason?` | `findRecordSales`/`getPurchaseReceipt(s)` on a malformed `RecordSoldEvent` | +| `MisoAuthError` | `code`, `reason`, `status?`, `cause?` | `@misofm/platform/auth` (`requestAuthorizationChallenge`, `authenticatedFetch`, …) | +| `ReleaseNotFoundError` | `releaseId` | `@misofm/platform/read` (`getReleaseResources`, `getReleaseDetail`, …) — the typed replacement for the api read service's old `isMissingRelease` message bridge | +| `ReceiptNotFoundError` | `digest` | `getPurchaseReceipt(s)` when neither the fullnode nor the indexer has the transaction | +| `RecordPurchaseNotFoundError` | `digest` | `getPurchaseReceipt(s)` when the transaction exists but carries no `RecordSoldEvent` | +| `ForeignPressingError` | `pressingId`, `actualType?` | reserved for read-layer Pressing type-mismatch bridging | + +```ts +import { Effect } from "effect"; + +const release = client.miso.protocol! + .getReleaseById(releaseId) + .pipe( + Effect.catchTags({ + ObjectNotFoundError: () => Effect.succeed(null), + SuiRpcError: (e) => Effect.die(e), // transport failure: not recoverable here + }), + ); +``` + ## Layout ``` diff --git a/packages/platform/package.json b/packages/platform/package.json index 3aa5a78..37c9e3e 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -1,6 +1,6 @@ { "name": "@misofm/platform", - "version": "0.25.0", + "version": "0.26.0", "packageManager": "bun@1.4.0", "description": "Complete client SDK for the Miso platform on Sui — high-level reads, pressings, listings, records, work publishing, Party extensions, and the first-party extensions built on top of the @misofm/musicos object model and the @misofm/partyos Party identity model.", "license": "Apache-2.0", @@ -130,6 +130,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": [ @@ -147,20 +151,23 @@ "prepare": "rm -rf dist && tsc -p tsconfig.build.json --noCheck" }, "dependencies": { - "@misofm/streaming": "0.2.1", + "@misofm/effect": "0.1.0", + "@misofm/streaming": "0.3.0", "@mysten/bcs": "^2.1.1", - "@misofm/musicos": "0.2.0", - "@misofm/partyos": "0.2.0" + "@misofm/musicos": "0.3.0", + "@misofm/partyos": "0.3.0" }, "devDependencies": { "@mysten/move-bytecode-template": "^0.4.0", "@mysten/sui": "2.29.0", "@types/bun": "^1.3.14", + "effect": "4.0.0-rc.112", "typescript": "^7.0.2" }, "peerDependencies": { "@mysten/move-bytecode-template": "^0.4.0", "@mysten/sui": "2.29.0", + "effect": "4.0.0-rc.112", "typescript": "^5.7 || ^6 || ^7" }, "main": "./dist/index.js" diff --git a/packages/platform/src/auth.ts b/packages/platform/src/auth.ts index 5913f1d..d1bc2c7 100644 --- a/packages/platform/src/auth.ts +++ b/packages/platform/src/auth.ts @@ -2,6 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { isValidSuiAddress, normalizeSuiAddress } from "@mysten/sui/utils"; +import { Effect } from "effect"; +import { MisoAuthError, type MisoAuthErrorCode } from "./errors.ts"; + +export type { MisoAuthErrorCode }; +export { MisoAuthError }; export type AuthNetwork = "testnet" | "mainnet"; @@ -31,24 +36,6 @@ export const MISO_AUTH_HEADERS = { issuedAt: "X-Sui-Issued-At", } as const; -export type MisoAuthErrorCode = - | "invalid_target" - | "challenge_rejected" - | "invalid_challenge" - | "signing_failed"; - -export class MisoAuthError extends Error { - readonly code: MisoAuthErrorCode; - readonly status?: number; - - constructor(code: MisoAuthErrorCode, message: string, options: { status?: number; cause?: unknown } = {}) { - super(message, options.cause === undefined ? undefined : { cause: options.cause }); - this.name = "MisoAuthError"; - this.code = code; - this.status = options.status; - } -} - /** The byte-exact Sui personal message required for an authenticated API mutation. */ export function buildApiAuthorizationPayload(fields: ApiAuthorizationFields): string { return [ @@ -86,20 +73,29 @@ export function parseFreshAuthorizationIssuedAt(input: unknown, nowMs: number = return input === canonical ? canonical : null; } -function target(method: string, path: string): { method: string; path: string } { +const target = Effect.fn("target")(function* ( + method: string, + path: string, +): Effect.fn.Return<{ method: string; path: string }, MisoAuthError> { const normalized = { method: method.toUpperCase(), path }; if (!isValidAuthorizationTarget(normalized.method, normalized.path)) { - throw new MisoAuthError("invalid_target", "Authenticated requests require a mutation under /platform/."); + return yield* new MisoAuthError({ + code: "invalid_target", + reason: "Authenticated requests require a mutation under /platform/.", + }); } return normalized; -} +}); -function parseChallenge( +const parseChallenge = Effect.fn("parseChallenge")(function* ( input: unknown, expected: { method: string; path: string; address: string; network?: AuthNetwork; nowMs?: number }, -): AuthorizationChallenge { +): Effect.fn.Return { if (input === null || typeof input !== "object" || Array.isArray(input)) { - throw new MisoAuthError("invalid_challenge", "The API returned an invalid authorization challenge."); + return yield* new MisoAuthError({ + code: "invalid_challenge", + reason: "The API returned an invalid authorization challenge.", + }); } const body = input as Partial>; if ( @@ -113,7 +109,10 @@ function parseChallenge( !isValidSuiAddress(body.address) || (body.network !== "testnet" && body.network !== "mainnet") ) { - throw new MisoAuthError("invalid_challenge", "The API returned an invalid authorization challenge."); + return yield* new MisoAuthError({ + code: "invalid_challenge", + reason: "The API returned an invalid authorization challenge.", + }); } const nowMs = expected.nowMs ?? Date.now(); @@ -143,20 +142,25 @@ function parseChallenge( expiresAtMs > Date.parse(issuedAt) + AUTHORIZATION_MAX_AGE_MS || body.payload !== buildApiAuthorizationPayload(challenge) ) { - throw new MisoAuthError("invalid_challenge", "The authorization challenge did not match this request."); + return yield* new MisoAuthError({ + code: "invalid_challenge", + reason: "The authorization challenge did not match this request.", + }); } return challenge; -} +}); -async function responseMessage(response: Response): Promise { - const body = await response.clone().json().catch(() => null) as { - error?: { message?: unknown } | string; - message?: unknown; - } | null; - if (typeof body?.error === "object" && typeof body.error.message === "string") return body.error.message; - if (typeof body?.error === "string") return body.error; - if (typeof body?.message === "string") return body.message; - return `Authorization challenge failed (${response.status}).`; +function responseMessage(response: Response): Effect.Effect { + return Effect.tryPromise(() => response.clone().json()).pipe( + Effect.map((body) => body as { error?: { message?: unknown } | string; message?: unknown } | null), + Effect.orElseSucceed(() => null), + Effect.map((body) => { + if (typeof body?.error === "object" && typeof body.error.message === "string") return body.error.message; + if (typeof body?.error === "string") return body.error; + if (typeof body?.message === "string") return body.message; + return `Authorization challenge failed (${response.status}).`; + }), + ); } export interface RequestAuthorizationChallengeOptions { @@ -172,57 +176,66 @@ export interface RequestAuthorizationChallengeOptions { } /** Ask Miso to verify the Enoki account and issue the exact message to sign. */ -export async function requestAuthorizationChallenge( +export const requestAuthorizationChallenge = Effect.fn("requestAuthorizationChallenge")(function* ( options: RequestAuthorizationChallengeOptions, -): Promise { - const expected = target(options.method, options.path); +): Effect.fn.Return { + const expected = yield* target(options.method, options.path); if (!options.token || !isValidSuiAddress(options.address)) { - throw new MisoAuthError("challenge_rejected", "A valid Enoki token and Sui address are required."); + return yield* new MisoAuthError({ + code: "challenge_rejected", + reason: "A valid Enoki token and Sui address are required.", + }); } const apiUrl = new URL(options.apiUrl); const challengeUrl = options.challengeUrl === undefined ? new URL("/platform/auth/challenge", apiUrl.origin) : new URL(options.challengeUrl, apiUrl); const fetcher = options.fetch ?? globalThis.fetch; - const response = await fetcher(challengeUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${options.token}`, - [MISO_AUTH_HEADERS.address]: normalizeSuiAddress(options.address), - "Content-Type": "application/json", - }, - body: JSON.stringify(expected), + const response = yield* Effect.tryPromise({ + try: (signal) => + fetcher(challengeUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${options.token}`, + [MISO_AUTH_HEADERS.address]: normalizeSuiAddress(options.address), + "Content-Type": "application/json", + }, + body: JSON.stringify(expected), + signal, + }), + catch: (cause) => new MisoAuthError({ code: "challenge_rejected", reason: "The authorization challenge request failed.", cause }), }); if (!response.ok) { - throw new MisoAuthError("challenge_rejected", await responseMessage(response), { status: response.status }); + const reason = yield* responseMessage(response); + return yield* new MisoAuthError({ code: "challenge_rejected", reason, status: response.status }); } - const body = await response.json().catch(() => null); - return parseChallenge(body, { + const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.orElseSucceed(() => null)); + return yield* parseChallenge(body, { ...expected, address: options.address, network: options.network, nowMs: options.nowMs, }); -} +}); export interface CreateAuthorizationHeadersOptions extends RequestAuthorizationChallengeOptions { signer: PersonalMessageSigner; } /** Verify the challenge locally, sign it as a Sui personal message, and produce API headers. */ -export async function createAuthorizationHeaders( +export const createAuthorizationHeaders = Effect.fn("createAuthorizationHeaders")(function* ( options: CreateAuthorizationHeadersOptions, -): Promise<{ challenge: AuthorizationChallenge; headers: Headers }> { - const challenge = await requestAuthorizationChallenge(options); - let signature: string; - try { - const signed = await options.signer.signPersonalMessage(new TextEncoder().encode(challenge.payload)); - signature = signed.signature; - } catch (cause) { - throw new MisoAuthError("signing_failed", "The authorization request was not signed.", { cause }); - } +): Effect.fn.Return<{ challenge: AuthorizationChallenge; headers: Headers }, MisoAuthError> { + const challenge = yield* requestAuthorizationChallenge(options); + const signature = yield* Effect.tryPromise({ + try: () => options.signer.signPersonalMessage(new TextEncoder().encode(challenge.payload)), + catch: (cause) => new MisoAuthError({ code: "signing_failed", reason: "The authorization request was not signed.", cause }), + }).pipe(Effect.map((signed) => signed.signature)); if (!signature) { - throw new MisoAuthError("signing_failed", "The signer returned an empty authorization signature."); + return yield* new MisoAuthError({ + code: "signing_failed", + reason: "The signer returned an empty authorization signature.", + }); } return { challenge, @@ -233,7 +246,7 @@ export async function createAuthorizationHeaders( [MISO_AUTH_HEADERS.issuedAt]: challenge.issuedAt, }), }; -} +}); export interface AuthenticatedFetchOptions extends RequestInit { auth: { @@ -247,18 +260,21 @@ export interface AuthenticatedFetchOptions extends RequestInit { } /** Perform a protected Miso API mutation with a fresh Enoki-verified Sui signature. */ -export async function authenticatedFetch( +export const authenticatedFetch = Effect.fn("authenticatedFetch")(function* ( input: string | URL, options: AuthenticatedFetchOptions, -): Promise { +): Effect.fn.Return { const { auth, challengeUrl, fetch: fetchOption, ...init } = options; const url = new URL(input); const method = (init.method ?? "").toUpperCase(); if (url.search || url.hash) { - throw new MisoAuthError("invalid_target", "Authenticated request URLs cannot include a query or fragment."); + return yield* new MisoAuthError({ + code: "invalid_target", + reason: "Authenticated request URLs cannot include a query or fragment.", + }); } const fetcher = fetchOption ?? globalThis.fetch; - const authorization = await createAuthorizationHeaders({ + const authorization = yield* createAuthorizationHeaders({ apiUrl: url, challengeUrl, fetch: fetcher, @@ -271,5 +287,8 @@ export async function authenticatedFetch( }); const headers = new Headers(init.headers); authorization.headers.forEach((value, name) => headers.set(name, value)); - return fetcher(url, { ...init, method, headers }); -} + return yield* Effect.tryPromise({ + try: (signal) => fetcher(url, { ...init, method, headers, signal }), + catch: (cause) => new MisoAuthError({ code: "invalid_target", reason: "The authenticated request failed.", cause }), + }); +}); diff --git a/packages/platform/src/catalog.ts b/packages/platform/src/catalog.ts index f41c8ae..a451b14 100644 --- a/packages/platform/src/catalog.ts +++ b/packages/platform/src/catalog.ts @@ -11,16 +11,16 @@ // to fetch, and chooses a concurrency/rate-limit tradeoff. Those are platform // calls, not protocol facts. -import type { ClientWithCoreApi } from "@mysten/sui/client"; -import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { Effect } from "effect"; import { extractTypeParams2, getOwnedRecordingAdminCaps, getRecordingsByIds, getReleaseById, + getWorkAddressesByShareTypes, type Recording, } from "@misofm/musicos"; -import { getWorkAddressesByShareTypes } from "./read/works.ts"; +import { SuiClient, SuiGraphQL, SuiRpcError, type BcsDecodeError, type ObjectNotFoundError } from "@misofm/effect"; import { getCompositionCreditsByIds, getRecordingCreditsByIds, @@ -58,42 +58,40 @@ const EMPTY_RECORDING_CREDITS: RecordingCreditsView = { * are then resolved concurrently, deduplicated, and all composition credit * fields are fetched concurrently as well. */ -export async function getReleaseTrackCredits( - client: ClientWithCoreApi, - graphqlClient: SuiGraphQLClient, +export const getReleaseTrackCredits = Effect.fn("getReleaseTrackCredits")(function* ( releaseId: string, options: GetReleaseTrackCreditsOptions, -): Promise> { - const release = await getReleaseById(client, releaseId); - return getTrackCreditsByRecordingIds( - client, - graphqlClient, +): Effect.fn.Return< + Record, + ObjectNotFoundError | SuiRpcError | BcsDecodeError, + SuiClient | SuiGraphQL +> { + const release = yield* getReleaseById(releaseId); + return yield* getTrackCreditsByRecordingIds( release.tracks.map((track) => track.recordingId), options, ); -} +}); /** * Composition and recording credits for the supplied recordings, keyed by * recording id. This form is intended for consumers that already loaded a * release and do not need to fetch it again. */ -export async function getTrackCreditsByRecordingIds( - client: ClientWithCoreApi, - graphqlClient: SuiGraphQLClient, +export const getTrackCreditsByRecordingIds = Effect.fn("getTrackCreditsByRecordingIds")(function* ( recordingIdsInput: readonly string[], options: GetReleaseTrackCreditsOptions, -): Promise> { +): Effect.fn.Return, SuiRpcError | BcsDecodeError, SuiClient | SuiGraphQL> { const recordingIds = [...new Set(recordingIdsInput)]; if (recordingIds.length === 0) return {}; - const [recordingCreditsById, recordingObjects] = await Promise.all([ - getRecordingCreditsByIds( - client, - recordingIds, - options.recordingCreditsPackageId, - ), - client.core.getObjects({ objectIds: recordingIds }), + const client = yield* SuiClient; + const [recordingCreditsById, recordingObjects] = yield* Effect.all([ + getRecordingCreditsByIds(recordingIds, options.recordingCreditsPackageId), + Effect.tryPromise({ + try: (signal) => client.core.getObjects({ objectIds: recordingIds, signal }), + catch: (cause) => new SuiRpcError({ operation: "getObjects", cause }), + }), ]); const recordingReads = recordingObjects.objects.map((object, index) => { const recordingId = recordingIds[index]!; @@ -106,11 +104,8 @@ export async function getTrackCreditsByRecordingIds( }; }); - const compositionShareTypes = [ - ...new Set(recordingReads.map((read) => read.compositionShareType)), - ]; - const addresses = await getWorkAddressesByShareTypes( - graphqlClient, + const compositionShareTypes = [...new Set(recordingReads.map((read) => read.compositionShareType))]; + const addresses = yield* getWorkAddressesByShareTypes( { compositions: compositionShareTypes, recordings: [] }, options.misoPackageId, ); @@ -120,14 +115,9 @@ export async function getTrackCreditsByRecordingIds( } } const compositionIds = [ - ...new Set( - Object.values(addresses.compositions).filter( - (id): id is string => id !== undefined, - ), - ), + ...new Set(Object.values(addresses.compositions).filter((id): id is string => id !== undefined)), ]; - const compositionCreditsById = await getCompositionCreditsByIds( - client, + const compositionCreditsById = yield* getCompositionCreditsByIds( compositionIds, options.compositionCreditsPackageId, ); @@ -144,7 +134,7 @@ export async function getTrackCreditsByRecordingIds( ]; }), ); -} +}); export interface GetAdministeredRecordingsOptions { /** @@ -160,30 +150,24 @@ export interface GetAdministeredRecordingsOptions { * Three bounded stages: list the owner's `RecordingAdminCap`s, resolve every * share type in one aliased GraphQL query, then batch-fetch the recordings. */ -export async function getAdministeredRecordings( - client: ClientWithCoreApi, - graphqlClient: SuiGraphQLClient, +export const getAdministeredRecordings = Effect.fn("getAdministeredRecordings")(function* ( owner: string, misoPackageId: string, options: GetAdministeredRecordingsOptions = {}, -): Promise { +): Effect.fn.Return { void options; - const caps = await getOwnedRecordingAdminCaps(client, owner, misoPackageId); + const caps = yield* getOwnedRecordingAdminCaps(owner, misoPackageId); if (caps.length === 0) return []; - const addresses = await getWorkAddressesByShareTypes( - graphqlClient, + const addresses = yield* getWorkAddressesByShareTypes( { compositions: [], recordings: caps.map((cap) => cap.shareType) }, misoPackageId, ); - const byId = await getRecordingsByIds( - client, - Object.values(addresses.recordings).filter( - (id): id is string => id !== undefined, - ), + const byId = yield* getRecordingsByIds( + Object.values(addresses.recordings).filter((id): id is string => id !== undefined), ); return caps.flatMap((cap) => { const id = addresses.recordings[cap.shareType]; const recording = id ? byId[id] : undefined; return recording ? [recording] : []; }); -} +}); diff --git a/packages/platform/src/client.ts b/packages/platform/src/client.ts index 22a45b8..cefb8b0 100644 --- a/packages/platform/src/client.ts +++ b/packages/platform/src/client.ts @@ -90,13 +90,13 @@ import { } from "./pressing.ts"; import type { GetSaleParams, - ListingView, + Listing, OpenListingParams, OpenPressingParams, + Pressing, PressingAdministrationParams, - PressingView, + PressingRecord, PurchaseRecordParams, - RecordView, SetListingPriceParams, SetListingStateParams, } from "./pressing.ts"; @@ -165,6 +165,25 @@ import { executeViaExecutor as executePlatformViaExecutor, type PlatformExecResult, } from "./execute.ts"; +import { Effect } from "effect"; +import { + SuiClient, + SuiRpcError, + type BcsDecodeError, + type ObjectTypeMismatchError, + type TransactionFailedError, +} from "@misofm/effect"; +import { + MisoChainIdentifierMismatchError, + MisoClientNotReadyError, + MisoNetworkMismatchError, +} from "./errors.ts"; + +export { + MisoChainIdentifierMismatchError, + MisoClientNotReadyError, + MisoNetworkMismatchError, +} from "./errors.ts"; type BoundMoveFunction = F extends (options: infer Options) => infer Result ? Options extends { package?: unknown } @@ -243,39 +262,6 @@ export interface MisoOptions { graphqlClient?: SuiGraphQLClient; } -export class MisoNetworkMismatchError extends Error { - override readonly name = "MisoNetworkMismatchError"; - constructor( - readonly clientNetwork: string, - readonly deploymentNetwork: string, - ) { - super( - `@misofm/platform: client network "${clientNetwork}" does not match deployment network "${deploymentNetwork}"`, - ); - } -} - -export class MisoChainIdentifierMismatchError extends Error { - override readonly name = "MisoChainIdentifierMismatchError"; - constructor( - readonly actual: string, - readonly expected: string, - ) { - super( - `@misofm/platform: endpoint chain identifier "${actual}" does not match deployment chain identifier "${expected}"`, - ); - } -} - -export class MisoClientNotReadyError extends Error { - override readonly name = "MisoClientNotReadyError"; - constructor(readonly operation: string) { - super( - `@misofm/platform: ${operation} requires an exact-chain validation lifecycle; call and await client.miso.ready() first`, - ); - } -} - export interface MisoPlatformConfig { /** Exact client network label for deprecated `misoPlatform()` registration. */ network?: string; @@ -411,7 +397,10 @@ export class MisoPlatformClient { readonly deployment?: MisoPlatformDeployment; #readyState: "unvalidated" | "validating" | "ready" | "failed" = "unvalidated"; - #readyPromise?: Promise; + /** Set once in the constructor via `Effect.cached`, so every caller of + * `ready()` shares the same in-flight/completed chain read. */ + readonly #readyEffect: Effect.Effect; + readonly #layer: ReturnType; constructor( client: ClientWithCoreApi, @@ -451,6 +440,38 @@ export class MisoPlatformClient { : undefined; this.#chainIdentifier = deploymentSnapshot?.chainIdentifier ?? configSnapshot.chainIdentifier; + this.#layer = SuiClient.layer(client); + // Built once; `Effect.cached` shares the same in-flight/completed chain + // read across every caller of `ready()` — the outer `Effect.cached(...)` + // itself only sets up the memoization ref, so running it synchronously + // here does not perform the underlying Core API read. + this.#readyEffect = Effect.runSync( + Effect.cached( + Effect.suspend(() => { + this.#readyState = "validating"; + if (!this.#chainIdentifier) { + return Effect.die( + new Error( + "misoPlatform: chain readiness requires a complete deployment or an explicit `chainIdentifier`.", + ), + ); + } + const expected = this.#chainIdentifier; + return Effect.tryPromise({ + try: (signal) => this.#client.core.getChainIdentifier({ signal }), + catch: (cause) => new SuiRpcError({ operation: "getChainIdentifier", cause }), + }).pipe( + Effect.flatMap(({ chainIdentifier }) => + chainIdentifier === expected + ? Effect.void + : new MisoChainIdentifierMismatchError({ actual: chainIdentifier, expected }), + ), + Effect.tap(() => Effect.sync(() => { this.#readyState = "ready"; })), + Effect.tapError(() => Effect.sync(() => { this.#readyState = "failed"; })), + ); + }), + ), + ); } /** Permissionless protocol APIs bound to the same validated ledger. */ @@ -465,38 +486,13 @@ export class MisoPlatformClient { * synchronous network-label mismatch; this Core API read protects every * client-bound write surface from a mislabeled or custom endpoint. */ - ready(): Promise { - if (!this.#readyPromise) { - this.#readyState = "validating"; - this.#readyPromise = (async () => { - try { - if (!this.#chainIdentifier) { - throw new Error( - "misoPlatform: chain readiness requires a complete deployment or an explicit `chainIdentifier`.", - ); - } - const { chainIdentifier } = - await this.#client.core.getChainIdentifier(); - if (chainIdentifier !== this.#chainIdentifier) { - throw new MisoChainIdentifierMismatchError( - chainIdentifier, - this.#chainIdentifier, - ); - } - this.#readyState = "ready"; - } catch (error) { - this.#readyState = "failed"; - throw error; - } - })(); - } - return this.#readyPromise; + ready(): Effect.Effect { + return this.#readyEffect; } - /** Compatibility hook; prefer `await client.miso.ready()`. */ - async validateChainIdentifier(): Promise { - await this.ready(); - return this.#chainIdentifier!; + /** Compatibility hook; prefer `client.miso.ready()`. */ + validateChainIdentifier(): Effect.Effect { + return this.ready().pipe(Effect.map(() => this.#chainIdentifier!)); } #requireReady(operation: string): void { @@ -523,10 +519,23 @@ export class MisoPlatformClient { this.#operations(); } if (this.#readyState !== "ready") { - throw new MisoClientNotReadyError(operation); + throw new MisoClientNotReadyError({ operation }); } } + /** + * Runs the exact-ledger readiness gate, then provides `SuiClient` from this + * client's `ClientWithCoreApi`, so a read method's program has `R = never`. + */ + #run( + effect: Effect.Effect, + ): Effect.Effect { + return this.ready().pipe( + Effect.flatMap(() => effect), + Effect.provide(this.#layer), + ); + } + /** Party identity and profile APIs, backed by the network SDK. */ get party(): PartyPlatformClient { if (!this.#party) { @@ -611,34 +620,47 @@ export class MisoPlatformClient { // ── Reads ───────────────────────────────────────────────────────────────── /** The run itself, or `null` if this release has never opened one. */ - async getPressing(pressingId: string): Promise { - await this.ready(); - return getPressing(this.#client, pressingId, this.recordPackageId); + getPressing( + pressingId: string, + ): Effect.Effect< + Pressing | null, + ObjectTypeMismatchError | BcsDecodeError | SuiRpcError | MisoChainIdentifierMismatchError + > { + return this.#run(getPressing(pressingId, this.recordPackageId)); } /** One currency's offer, or `null` if the run does not sell in it. */ - async getListing(listingId: string): Promise { - await this.ready(); - return getListing(this.#client, listingId, this.recordShopPackageId); + getListing( + listingId: string, + ): Effect.Effect< + Listing | null, + ObjectTypeMismatchError | BcsDecodeError | SuiRpcError | MisoChainIdentifierMismatchError + > { + return this.#run(getListing(listingId, this.recordShopPackageId)); } /** One concrete purchased Record, including immutable purchase provenance. */ - async getRecord(recordId: string): Promise { - await this.ready(); - return getRecord(this.#client, recordId, this.recordPackageId); + getRecord( + recordId: string, + ): Effect.Effect< + PressingRecord | null, + ObjectTypeMismatchError | BcsDecodeError | SuiRpcError | MisoChainIdentifierMismatchError + > { + return this.#run(getRecord(recordId, this.recordPackageId)); } /** Run + one currency's offer in a single round trip, by address math. */ - async getSale(p: Configured): Promise<{ - pressing: PressingView | null; - listing: ListingView | null; - }> { - await this.ready(); - return getSale(this.#client, { - ...p, - recordPackageId: this.recordPackageId, - recordShopPackageId: this.recordShopPackageId, - }); + getSale(p: Configured): Effect.Effect< + { pressing: Pressing | null; listing: Listing | null }, + ObjectTypeMismatchError | BcsDecodeError | SuiRpcError | MisoChainIdentifierMismatchError + > { + return this.#run( + getSale({ + ...p, + recordPackageId: this.recordPackageId, + recordShopPackageId: this.recordShopPackageId, + }), + ); } // ── Address math ────────────────────────────────────────────────────────── @@ -898,21 +920,19 @@ export class MisoPlatformClient { // ── Share currency provisioning (executes; Signer pattern) ───────────────── /** Publishes + initializes a fresh share currency (two txs). */ - async createShareCurrency( + createShareCurrency( signer: Signer, params: share.CreateShareCurrencyParams, - ): Promise { - await this.ready(); - return share.createShareCurrency(this.#client, signer, params); + ): Effect.Effect { + return this.#run(share.createShareCurrency(signer, params)); } /** Execute a composed platform PTB only after exact-ledger validation. */ - async executeViaExecutor( + executeViaExecutor( executor: ParallelTransactionExecutor, ...thunks: TxThunk[] - ): Promise { - await this.ready(); - return executePlatformViaExecutor(executor, ...thunks); + ): Effect.Effect { + return this.ready().pipe(Effect.flatMap(() => executePlatformViaExecutor(executor, ...thunks))); } // ── Generated layer ─────────────────────────────────────────────────────── @@ -1304,7 +1324,7 @@ export function miso( options.deployment ?? getMisoPlatformDeployment(client.network), ); if (deployment.network !== client.network) { - throw new MisoNetworkMismatchError(client.network, deployment.network); + throw new MisoNetworkMismatchError({ clientNetwork: client.network, deploymentNetwork: deployment.network }); } const protocol = protocolMiso({ deployment: deployment.protocol, @@ -1356,7 +1376,7 @@ export function misoPlatform(config: MisoPlatformConfig) { register: (client: ClientWithCoreApi) => { const configSnapshot = immutableSnapshot(config); if (configSnapshot.network && configSnapshot.network !== client.network) { - throw new MisoNetworkMismatchError(client.network, configSnapshot.network); + throw new MisoNetworkMismatchError({ clientNetwork: client.network, deploymentNetwork: configSnapshot.network }); } const protocol = configSnapshot.misoPackageId ? protocolMiso({ diff --git a/packages/platform/src/cover.ts b/packages/platform/src/cover.ts index e200beb..be8a819 100644 --- a/packages/platform/src/cover.ts +++ b/packages/platform/src/cover.ts @@ -14,10 +14,11 @@ // opinion attached to it through the release's cap-gated `uid_mut` hook, which is // why this module ships from `@misofm/platform` rather than the protocol SDK. -import type { ClientWithCoreApi } from "@mysten/sui/client"; import { bcs } from "@mysten/sui/bcs"; import { deriveDynamicFieldID } from "@mysten/sui/utils"; import type { Transaction, TransactionObjectArgument } from "@mysten/sui/transactions"; +import { Effect } from "effect"; +import { getObjectsContent, type SuiClient, type SuiRpcError } from "@misofm/effect"; import type { TxThunk } from "./transactions.ts"; import { asU64, directAdminCap, invokeWithAdminCap, type AdminCapAuthority, type ObjectInput, type U64Input } from "./vault.ts"; import { OPTION_NONE, OPTION_SOME, unencryptedWalrusBlob } from "./internal.ts"; @@ -149,17 +150,13 @@ function toCoverImageRef(blob: ParsedWalrusBlob): CoverImageRef { * release, parses the `ReleaseCoverArt`, and returns the still (+ optional * animation) as normalized Walrus refs for release displays. */ -export async function getReleaseCover( - client: ClientWithCoreApi, +export const getReleaseCover = Effect.fn("getReleaseCover")(function* ( releaseId: string, releaseCoverArtPackageId: string, -): Promise { - return ( - ( - await getReleaseCoversByIds(client, [releaseId], releaseCoverArtPackageId) - )[releaseId] ?? null - ); -} +): Effect.fn.Return { + const found = yield* getReleaseCoversByIds([releaseId], releaseCoverArtPackageId); + return found[releaseId] ?? null; +}); export function parseReleaseCoverContent( content: Uint8Array, @@ -191,11 +188,10 @@ export function releaseCoverFieldId( /** * Read covers for many releases from the configured package in one Core request. */ -export async function getReleaseCoversByIds( - client: ClientWithCoreApi, +export const getReleaseCoversByIds = Effect.fn("getReleaseCoversByIds")(function* ( releaseIdsInput: readonly string[], releaseCoverArtPackageId: string, -): Promise>> { +): Effect.fn.Return>, SuiRpcError, SuiClient> { const releaseIds = [...new Set(releaseIdsInput)]; const targets = releaseIds.map((releaseId) => ({ releaseId, @@ -203,18 +199,13 @@ export async function getReleaseCoversByIds( })); if (targets.length === 0) return {}; - const { objects } = await client.core.getObjects({ - objectIds: targets.map((target) => target.fieldId), - include: { content: true }, - }); + const contentById = yield* getObjectsContent(targets.map((target) => target.fieldId)); const out: Partial> = {}; - objects.forEach((object, index) => { - const target = targets[index]; - if (!target || object instanceof Error || !object.content) return; - const cover = parseReleaseCoverContent(object.content); - if (cover) { - out[target.releaseId] = cover; - } - }); + for (const target of targets) { + const found = contentById.get(target.fieldId); + if (!found) continue; + const cover = parseReleaseCoverContent(found.content); + if (cover) out[target.releaseId] = cover; + } return out; -} +}); diff --git a/packages/platform/src/credits.ts b/packages/platform/src/credits.ts index 0eca664..1b3d092 100644 --- a/packages/platform/src/credits.ts +++ b/packages/platform/src/credits.ts @@ -28,9 +28,10 @@ // about any of it — it only provides the cap-gated `uid_mut` hook these attach // through. -import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { Effect, Option } from "effect"; import { bcs } from "@mysten/sui/bcs"; import { deriveDynamicFieldID } from "@mysten/sui/utils"; +import { getObjectsContent, SuiClient, type SuiRpcError } from "@misofm/effect"; import type { Transaction, TransactionArgument, @@ -632,23 +633,18 @@ interface CreditFieldTarget { } /** Fetch many derived credit fields through one Core bulk request. */ -async function fetchCreditFields( - client: ClientWithCoreApi, +const fetchCreditFields = Effect.fn("fetchCreditFields")(function* ( targets: readonly CreditFieldTarget[], -): Promise> { +): Effect.fn.Return, SuiRpcError, SuiClient> { if (targets.length === 0) return new Map(); - const { objects } = await client.core.getObjects({ - objectIds: targets.map((target) => target.fieldId), - include: { content: true }, - }); + const objects = yield* getObjectsContent(targets.map((target) => target.fieldId)); const contents = new Map(); - objects.forEach((object, index) => { - if (object instanceof Error || !object.content) return; - const target = targets[index]; - if (target) contents.set(target.fieldId, object.content); - }); + for (const target of targets) { + const object = objects.get(target.fieldId); + if (object) contents.set(target.fieldId, object.content); + } return contents; -} +}); function compositionCreditTargets( compositionIds: readonly string[], @@ -783,34 +779,23 @@ function recordingRoleLabel(role: ParsedEnum): string { * Reads a composition's writing credits (the `composition_credits` extension), or * `null` if no credits field is attached. */ -export async function getCompositionCredits( - client: ClientWithCoreApi, +/** Reads a composition's writing credits (the `composition_credits` extension). */ +export const getCompositionCredits = Effect.fn("getCompositionCredits")(function* ( compositionId: string, compositionCreditsPackageId: string, -): Promise { - return ( - ( - await getCompositionCreditsByIds( - client, - [compositionId], - compositionCreditsPackageId, - ) - )[compositionId] ?? null - ); -} +): Effect.fn.Return, SuiRpcError, SuiClient> { + const byId = yield* getCompositionCreditsByIds([compositionId], compositionCreditsPackageId); + return Option.fromNullishOr(byId[compositionId]); +}); /** Composition credits for many works in one Core bulk request. */ -export async function getCompositionCreditsByIds( - client: ClientWithCoreApi, +export const getCompositionCreditsByIds = Effect.fn("getCompositionCreditsByIds")(function* ( compositionIdsInput: readonly string[], compositionCreditsPackageId: string, -): Promise>> { +): Effect.fn.Return>, SuiRpcError, SuiClient> { const compositionIds = [...new Set(compositionIdsInput)]; - const targets = compositionCreditTargets( - compositionIds, - compositionCreditsPackageId, - ); - const contents = await fetchCreditFields(client, targets); + const targets = compositionCreditTargets(compositionIds, compositionCreditsPackageId); + const contents = yield* fetchCreditFields(targets); const out: Partial> = {}; for (const target of targets) { const content = contents.get(target.fieldId); @@ -822,40 +807,28 @@ export async function getCompositionCreditsByIds( } } return out; -} +}); /** * Reads a recording's credits plus its primary/featured artist party ids (the - * `recording_credits` extension), or `null` if no credits field is attached. + * `recording_credits` extension). */ -export async function getRecordingCredits( - client: ClientWithCoreApi, +export const getRecordingCredits = Effect.fn("getRecordingCredits")(function* ( recordingId: string, recordingCreditsPackageId: string, -): Promise { - return ( - ( - await getRecordingCreditsByIds( - client, - [recordingId], - recordingCreditsPackageId, - ) - )[recordingId] ?? null - ); -} +): Effect.fn.Return, SuiRpcError, SuiClient> { + const byId = yield* getRecordingCreditsByIds([recordingId], recordingCreditsPackageId); + return Option.fromNullishOr(byId[recordingId]); +}); /** Recording credits for many works in one Core bulk request. */ -export async function getRecordingCreditsByIds( - client: ClientWithCoreApi, +export const getRecordingCreditsByIds = Effect.fn("getRecordingCreditsByIds")(function* ( recordingIdsInput: readonly string[], recordingCreditsPackageId: string, -): Promise>> { +): Effect.fn.Return>, SuiRpcError, SuiClient> { const recordingIds = [...new Set(recordingIdsInput)]; - const targets = recordingCreditTargets( - recordingIds, - recordingCreditsPackageId, - ); - const contents = await fetchCreditFields(client, targets); + const targets = recordingCreditTargets(recordingIds, recordingCreditsPackageId); + const contents = yield* fetchCreditFields(targets); const out: Partial> = {}; for (const target of targets) { const content = contents.get(target.fieldId); @@ -868,33 +841,28 @@ export async function getRecordingCreditsByIds( }; } return out; -} +}); /** - * Reads a release's top-line billing credits (the `release_credits` extension), or - * `null` if no credits field is attached. Each credit has exactly one role. + * Reads a release's top-line billing credits (the `release_credits` extension). + * Each credit has exactly one role. */ -export async function getReleaseCredits( - client: ClientWithCoreApi, +export const getReleaseCredits = Effect.fn("getReleaseCredits")(function* ( releaseId: string, releaseCreditsPackageId: string, -): Promise { - return ( - ( - await getReleaseCreditsByIds(client, [releaseId], releaseCreditsPackageId) - )[releaseId] ?? null - ); -} +): Effect.fn.Return, SuiRpcError, SuiClient> { + const byId = yield* getReleaseCreditsByIds([releaseId], releaseCreditsPackageId); + return Option.fromNullishOr(byId[releaseId]); +}); /** Release billing credits for many releases in one Core bulk request. */ -export async function getReleaseCreditsByIds( - client: ClientWithCoreApi, +export const getReleaseCreditsByIds = Effect.fn("getReleaseCreditsByIds")(function* ( releaseIdsInput: readonly string[], releaseCreditsPackageId: string, -): Promise>> { +): Effect.fn.Return>, SuiRpcError, SuiClient> { const releaseIds = [...new Set(releaseIdsInput)]; const targets = releaseCreditTargets(releaseIds, releaseCreditsPackageId); - const contents = await fetchCreditFields(client, targets); + const contents = yield* fetchCreditFields(targets); const out: Partial> = {}; for (const target of targets) { const content = contents.get(target.fieldId); @@ -903,4 +871,4 @@ export async function getReleaseCreditsByIds( } } return out; -} +}); diff --git a/packages/platform/src/deployments.ts b/packages/platform/src/deployments.ts index 163dc59..9a3ff84 100644 --- a/packages/platform/src/deployments.ts +++ b/packages/platform/src/deployments.ts @@ -6,7 +6,20 @@ import { MISO_DEPLOYMENTS } from "@misofm/musicos/deployments"; import type { PartyDeployment } from "@misofm/partyos/deployments"; import { PARTYOS_DEPLOYMENTS } from "@misofm/partyos/deployments"; import { normalizeSuiAddress, normalizeSuiObjectId } from "@mysten/sui/utils"; +import { Context, Effect, Layer } from "effect"; +import { DeploymentError } from "@misofm/effect/errors"; import { immutableSnapshot } from "./internal.ts"; +import { + MisoPlatformDeploymentInvalidError, + OperationsUnavailableError, + RecordSalesUnavailableError, +} from "./errors.ts"; + +export { + MisoPlatformDeploymentInvalidError, + OperationsUnavailableError, + RecordSalesUnavailableError, +} from "./errors.ts"; /** Every package address a platform deployment must supply, for canonical-id * and pairwise-distinctness validation in {@link assertMisoPlatformDeployment}. */ @@ -66,20 +79,13 @@ export type RecordSalesDeployment = readonly recordShopPackageId: string; }; -export class RecordSalesUnavailableError extends Error { - override readonly name = "RecordSalesUnavailableError"; - constructor(readonly reason: string) { - super(`@misofm/platform: Record sales are unavailable: ${reason}`); - } -} - export function requireRecordSalesDeployment( deployment: RecordSalesDeployment | undefined, ): Extract { if (!deployment || deployment.status === "unavailable") { - throw new RecordSalesUnavailableError( - deployment?.reason ?? "no Record sales deployment was configured", - ); + throw new RecordSalesUnavailableError({ + reason: deployment?.reason ?? "no Record sales deployment was configured", + }); } const canonicalPackageId = (value: string): boolean => { if (!/^0x[0-9a-f]{64}$/.test(value)) return false; @@ -93,14 +99,14 @@ export function requireRecordSalesDeployment( !canonicalPackageId(deployment.recordPackageId) || !canonicalPackageId(deployment.recordShopPackageId) ) { - throw new RecordSalesUnavailableError( - "Record and Record Shop package IDs must be canonical 32-byte Sui object IDs", - ); + throw new RecordSalesUnavailableError({ + reason: "Record and Record Shop package IDs must be canonical 32-byte Sui object IDs", + }); } if (deployment.recordPackageId === deployment.recordShopPackageId) { - throw new RecordSalesUnavailableError( - "Record and Record Shop package IDs must be distinct", - ); + throw new RecordSalesUnavailableError({ + reason: "Record and Record Shop package IDs must be distinct", + }); } return deployment; } @@ -137,13 +143,6 @@ export type OperationsDeployment = }; }; -export class OperationsUnavailableError extends Error { - override readonly name = "OperationsUnavailableError"; - constructor(readonly reason: string) { - super(`@misofm/platform: Vault operations are unavailable: ${reason}`); - } -} - export type AvailableOperationsDeployment = Extract< OperationsDeployment, { status: "available" } @@ -172,9 +171,9 @@ export function requireOperationsDeployment( deployment: OperationsDeployment | undefined, ): AvailableOperationsDeployment { if (!deployment || deployment.status !== "available") { - throw new OperationsUnavailableError( - deployment?.reason ?? "no Vault operations deployment was configured", - ); + throw new OperationsUnavailableError({ + reason: deployment?.reason ?? "no Vault operations deployment was configured", + }); } const candidate = deployment as Partial; @@ -201,15 +200,15 @@ export function requireOperationsDeployment( for (const [field, id] of packageEntries) { if (!canonicalObjectId(id)) { - throw new OperationsUnavailableError( - `${field} must be a canonical 32-byte Sui package ID`, - ); + throw new OperationsUnavailableError({ + reason: `${field} must be a canonical 32-byte Sui package ID`, + }); } } if (!canonicalObjectId(vault?.registryId)) { - throw new OperationsUnavailableError( - "vault.registryId must be a canonical 32-byte Sui object ID", - ); + throw new OperationsUnavailableError({ + reason: "vault.registryId must be a canonical 32-byte Sui object ID", + }); } const identities = [ @@ -217,9 +216,9 @@ export function requireOperationsDeployment( vault.registryId, ]; if (new Set(identities).size !== identities.length) { - throw new OperationsUnavailableError( - `the Vault registry object and all ${packageEntries.length} package IDs must be distinct`, - ); + throw new OperationsUnavailableError({ + reason: `the Vault registry object and all ${packageEntries.length} package IDs must be distinct`, + }); } return deployment; } @@ -316,10 +315,6 @@ export interface MisoPlatformDeployment { }; } -export class MisoPlatformDeploymentInvalidError extends Error { - override readonly name = "MisoPlatformDeploymentInvalidError"; -} - /** * Validates the `packages` and `party` package-id sets before any Move target * is constructed from them: every required id must be a canonical 32-byte Sui @@ -331,28 +326,28 @@ export function assertMisoPlatformDeployment( deployment: unknown, ): asserts deployment is MisoPlatformDeployment { if (!deployment || typeof deployment !== "object" || Array.isArray(deployment)) { - throw new MisoPlatformDeploymentInvalidError( - "@misofm/platform: deployment must be a complete MisoPlatformDeployment object.", - ); + throw new MisoPlatformDeploymentInvalidError({ + message: "@misofm/platform: deployment must be a complete MisoPlatformDeployment object.", + }); } const candidate = deployment as Partial; const packages = candidate.packages as Partial | undefined; const party = candidate.party as Partial | undefined; const partyos = candidate.partyos as Partial | undefined; if (!packages || typeof packages !== "object") { - throw new MisoPlatformDeploymentInvalidError( - "@misofm/platform: deployment is missing its `packages` section.", - ); + throw new MisoPlatformDeploymentInvalidError({ + message: "@misofm/platform: deployment is missing its `packages` section.", + }); } if (!party || typeof party !== "object") { - throw new MisoPlatformDeploymentInvalidError( - "@misofm/platform: deployment is missing its `party` section.", - ); + throw new MisoPlatformDeploymentInvalidError({ + message: "@misofm/platform: deployment is missing its `party` section.", + }); } if (!partyos || typeof partyos !== "object") { - throw new MisoPlatformDeploymentInvalidError( - "@misofm/platform: deployment is missing its `partyos` section.", - ); + throw new MisoPlatformDeploymentInvalidError({ + message: "@misofm/platform: deployment is missing its `partyos` section.", + }); } const seenPackageIds = new Set(); @@ -363,29 +358,30 @@ export function assertMisoPlatformDeployment( * manifest. */ const checkCanonical = (field: string, packageId: unknown, dedupeOk = false) => { if (typeof packageId !== "string") { - throw new MisoPlatformDeploymentInvalidError( - `@misofm/platform: deployment is missing package ID for "${field}".`, - ); + throw new MisoPlatformDeploymentInvalidError({ + message: `@misofm/platform: deployment is missing package ID for "${field}".`, + }); } let normalized: string; try { normalized = normalizeSuiAddress(packageId); } catch { - throw new MisoPlatformDeploymentInvalidError( - `@misofm/platform: deployment package ID for "${field}" is not a valid Sui address.`, - ); + throw new MisoPlatformDeploymentInvalidError({ + message: `@misofm/platform: deployment package ID for "${field}" is not a valid Sui address.`, + }); } if (packageId !== normalized) { - throw new MisoPlatformDeploymentInvalidError( - `@misofm/platform: deployment package ID for "${field}" must be normalized (${normalized}).`, - ); + throw new MisoPlatformDeploymentInvalidError({ + message: `@misofm/platform: deployment package ID for "${field}" must be normalized (${normalized}).`, + }); } if (!dedupeOk) { if (seenPackageIds.has(packageId)) { - throw new MisoPlatformDeploymentInvalidError( - `@misofm/platform: deployment package ID for "${field}" duplicates another package. ` + + throw new MisoPlatformDeploymentInvalidError({ + message: + `@misofm/platform: deployment package ID for "${field}" duplicates another package. ` + "Every package in a deployment manifest must have its own address.", - ); + }); } seenPackageIds.add(packageId); } @@ -401,14 +397,16 @@ export function assertMisoPlatformDeployment( checkCanonical(`party.${key}`, party[key], key === "countryCode" || key === "languageCode"); } if (party.countryCode !== packages.countryCode) { - throw new MisoPlatformDeploymentInvalidError( - "@misofm/platform: party.countryCode must match packages.countryCode (the same shared dependency package).", - ); + throw new MisoPlatformDeploymentInvalidError({ + message: + "@misofm/platform: party.countryCode must match packages.countryCode (the same shared dependency package).", + }); } if (party.languageCode !== packages.languageCode) { - throw new MisoPlatformDeploymentInvalidError( - "@misofm/platform: party.languageCode must match packages.languageCode (the same shared dependency package).", - ); + throw new MisoPlatformDeploymentInvalidError({ + message: + "@misofm/platform: party.languageCode must match packages.languageCode (the same shared dependency package).", + }); } } @@ -572,3 +570,39 @@ export function getMisoPlatformDeployment( } return deployment; } + +/** Effect-returning `assertMisoPlatformDeployment`: validation failure becomes a typed `DeploymentError`. */ +export function validateMisoPlatformDeployment( + deployment: unknown, +): Effect.Effect { + return Effect.try({ + try: () => normalizeMisoPlatformDeployment(deployment), + catch: (cause) => + new DeploymentError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); +} + +/** + * The platform deployment manifest as a service, so the deployment-aware + * platform reads and PTB builders (notably {@link Genre.derive}, which needs + * the genre registry + package ids) can declare it in `R` instead of taking it + * as an explicit parameter. `MisoPlatformClient`/`misoPlatform()` provide this + * layer internally; a bare read composed from `queries.ts` functions must + * provide it explicitly, e.g. + * `program.pipe(Effect.provide(PlatformDeployment.layer(deployment)))`. + */ +export class PlatformDeployment extends Context.Service()( + "@misofm/platform/PlatformDeployment", +) { + /** Provides a concrete, already-validated `MisoPlatformDeployment` as the service. */ + static layer(deployment: MisoPlatformDeployment): Layer.Layer { + return Layer.succeed(PlatformDeployment, deployment); + } + + /** Resolves a bundled deployment for `network` and provides it as the service; fails closed for unknown networks. */ + static fromNetwork(network: string): Layer.Layer { + return PlatformDeployment.layer(getMisoPlatformDeployment(network)); + } +} diff --git a/packages/platform/src/errors.ts b/packages/platform/src/errors.ts new file mode 100644 index 0000000..e6c4bba --- /dev/null +++ b/packages/platform/src/errors.ts @@ -0,0 +1,156 @@ +// Copyright (c) Miso Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// The platform-specific error vocabulary, built on the shared foundation +// (`@misofm/effect/errors`). Every failure a caller might need to act on is a +// `Schema.TaggedError` carrying the fields needed to act; `isNotFound`-style +// message sniffing is replaced by `Effect.catchTag` against these tags. + +import { Schema } from "effect"; + +// Re-export the foundation vocabulary so a platform consumer never needs a +// second import for the errors platform reads/executes surface directly. +export { + BcsDecodeError, + DeploymentError, + GraphQLUnavailableError, + ObjectNotFoundError, + ObjectTypeMismatchError, + SuiRpcError, + TransactionFailedError, + type SuiReadError, +} from "@misofm/effect/errors"; + +/** Record sales (Record + Record Shop) are unavailable on this deployment. */ +export class RecordSalesUnavailableError extends Schema.TaggedError()( + "RecordSalesUnavailableError", + { + reason: Schema.String, + }, +) { + override get message(): string { + return `@misofm/platform: Record sales are unavailable: ${this.reason}`; + } +} + +/** Vault/Action/plugin operations are unavailable on this deployment. */ +export class OperationsUnavailableError extends Schema.TaggedError()( + "OperationsUnavailableError", + { + reason: Schema.String, + }, +) { + override get message(): string { + return `@misofm/platform: Vault operations are unavailable: ${this.reason}`; + } +} + +/** A caller-supplied `MisoPlatformDeployment` manifest failed structural validation. */ +export class MisoPlatformDeploymentInvalidError extends Schema.TaggedError()( + "MisoPlatformDeploymentInvalidError", + { + message: Schema.String, + }, +) {} + +/** The connected client's network does not match the configured deployment's network. */ +export class MisoNetworkMismatchError extends Schema.TaggedError()( + "MisoNetworkMismatchError", + { + clientNetwork: Schema.String, + deploymentNetwork: Schema.String, + }, +) { + override get message(): string { + return `@misofm/platform: client network "${this.clientNetwork}" does not match deployment network "${this.deploymentNetwork}"`; + } +} + +/** The connected endpoint's chain identifier does not match the configured deployment's. */ +export class MisoChainIdentifierMismatchError extends Schema.TaggedError()( + "MisoChainIdentifierMismatchError", + { + actual: Schema.String, + expected: Schema.String, + }, +) { + override get message(): string { + return `@misofm/platform: endpoint chain identifier "${this.actual}" does not match deployment chain identifier "${this.expected}"`; + } +} + +/** A method that requires the exact-chain validation lifecycle was called before `ready()`. */ +export class MisoClientNotReadyError extends Schema.TaggedError()( + "MisoClientNotReadyError", + { + operation: Schema.String, + }, +) { + override get message(): string { + return `@misofm/platform: ${this.operation} requires an exact-chain validation lifecycle; call and await client.miso.ready() first`; + } +} + +/** An on-chain `RecordSoldEvent` could not be decoded into a `RecordSale`. */ +export class MalformedRecordSoldEventError extends Schema.TaggedError()( + "MalformedRecordSoldEventError", + { + digest: Schema.optional(Schema.String), + reason: Schema.optional(Schema.String), + }, +) {} + +/** Authenticated-fetch / API-authorization signing or verification failure. */ +export const MisoAuthErrorCode = Schema.Literals([ + "invalid_target", + "challenge_rejected", + "invalid_challenge", + "signing_failed", +]); +export type MisoAuthErrorCode = typeof MisoAuthErrorCode.Type; + +export class MisoAuthError extends Schema.TaggedError()("MisoAuthError", { + code: MisoAuthErrorCode, + reason: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return this.reason; + } +} + +// ── Domain not-found wrappers ─────────────────────────────────────────────── +// +// Added where a caller benefits from a typed tag instead of message-sniffing — +// concretely, `misofm/api`'s read service hand-rolls exactly this bridge today +// (`services/miso-read-service/src/routes.ts`: `isMissingRelease`, +// `isMissingReceipt`). These let that bridge use `Effect.catchTag` instead. + +/** No Release exists at the requested id (or it is not a Release from the configured deployment). */ +export class ReleaseNotFoundError extends Schema.TaggedError()("ReleaseNotFoundError", { + releaseId: Schema.String, +}) {} + +/** The fullnode and the indexer both have no record of this transaction digest. */ +export class ReceiptNotFoundError extends Schema.TaggedError()("ReceiptNotFoundError", { + digest: Schema.String, +}) {} + +/** The transaction exists, but its effects contain no `record_shop::listing::RecordSoldEvent`. */ +export class RecordPurchaseNotFoundError extends Schema.TaggedError()( + "RecordPurchaseNotFoundError", + { + digest: Schema.String, + }, +) {} + +/** An object exists at the requested Pressing id, but is not a Pressing from the configured Record package. */ +export class ForeignPressingError extends Schema.TaggedError()("ForeignPressingError", { + pressingId: Schema.String, + actualType: Schema.optional(Schema.String), +}) { + override get message(): string { + return `@misofm/platform: object ${this.pressingId} is not a Pressing from the configured Record package.`; + } +} diff --git a/packages/platform/src/execute.ts b/packages/platform/src/execute.ts index 91a8c0e..bc67456 100644 --- a/packages/platform/src/execute.ts +++ b/packages/platform/src/execute.ts @@ -10,7 +10,9 @@ import type { ParallelTransactionExecutor } from "@mysten/sui/transactions"; import type { SuiClientTypes } from "@mysten/sui/client"; +import { Effect } from "effect"; import { buildTx, toExecResult, FULL_INCLUDE, type ExecResult } from "@misofm/musicos"; +import { SuiRpcError } from "@misofm/effect/errors"; import type { TxThunk } from "./transactions.ts"; // Consumers of the platform SDK should not need a second direct dependency on @@ -39,14 +41,16 @@ export interface PlatformExecResult extends ExecResult { * job (e.g. a resumable checkpoint that reconciles against on-chain state). A Move * abort RESOLVES as a `FailedTransaction`, so `toExecResult` surfaces it too. */ -export async function executeViaExecutor( +export const executeViaExecutor = Effect.fn("executeViaExecutor")(function* ( executor: ParallelTransactionExecutor, ...thunks: TxThunk[] -): Promise { - const tx = await buildTx(...thunks); - const res: SuiClientTypes.TransactionResult = - await executor.executeTransaction(tx, PLATFORM_FULL_INCLUDE); +): Effect.fn.Return { + const tx = yield* buildTx(...thunks); + const res: SuiClientTypes.TransactionResult = yield* Effect.tryPromise({ + try: () => executor.executeTransaction(tx, PLATFORM_FULL_INCLUDE), + catch: (cause) => new SuiRpcError({ operation: "executeTransaction", cause }), + }); const base = toExecResult(res); if (res.$kind !== "Transaction") throw new Error("unreachable: toExecResult accepted a failed transaction"); return { ...base, events: res.Transaction.events ?? [] }; -} +}); diff --git a/packages/platform/src/genre.ts b/packages/platform/src/genre.ts index 4cae23f..a83570d 100644 --- a/packages/platform/src/genre.ts +++ b/packages/platform/src/genre.ts @@ -40,12 +40,14 @@ * `genres()[0]`, computed client-side the same way Move computes them. */ -import type { ClientWithCoreApi } from "@mysten/sui/client"; import { bcs } from "@mysten/sui/bcs"; import { deriveDynamicFieldID, deriveObjectID, normalizeSuiObjectId } from "@mysten/sui/utils"; import type { Transaction, TransactionObjectArgument } from "@mysten/sui/transactions"; +import { Effect, Schema } from "effect"; +import { getOptionalObjectContent, type SuiClient, type SuiRpcError } from "@misofm/effect"; import type { TxThunk } from "./transactions.ts"; import { directAdminCap, invokeWithAdminCap, type AdminCapAuthority, type ObjectInput } from "./vault.ts"; +import { PlatformDeployment } from "./deployments.ts"; import type { ReleaseExtensionTarget } from "./release-extensions.ts"; import type { RecordingExtensionTarget } from "./recording-extensions.ts"; import * as genre from "./contracts/genre/genre.ts"; @@ -82,6 +84,31 @@ export function deriveGenreAddress( ); } +/** + * A vocabulary entry from the shared `genre` registry, identified by its + * canonical name (e.g. `"HIP_HOP"`). Constructing one needs the deployment's + * genre registry object id and genre package id — see {@link Genre.derive}. + */ +export class Genre extends Schema.Class("@misofm/platform/Genre")({ + id: Schema.String, + name: Schema.String, +}) { + /** + * Derives a `Genre`'s immutable object address from the platform + * deployment's genre registry + package id. This is a pure derivation (no + * chain read) but is the pilot for a value object whose construction + * genuinely needs deployment context, so it reaches that context through + * the `PlatformDeployment` service rather than an explicit parameter. + */ + static readonly derive = Effect.fn("Genre.derive")(function* ( + canonicalName: string, + ): Effect.fn.Return { + const deployment = yield* PlatformDeployment; + const id = deriveGenreAddress(deployment.objects.genreRegistry, deployment.packages.genre, canonicalName); + return new Genre({ id, name: canonicalName }); + }); +} + // ── Shared validation ──────────────────────────────────────────────────────── /** The maximum number of genres `release_genre` / `recording_genre` will accept. */ @@ -334,19 +361,13 @@ export function recordingGenresFieldId( * are attached. The primary is `list[0]`; there is no separate primary reader * because the Move `genres` view already returns `[]` for the absent case. */ -export async function getReleaseGenres( - client: ClientWithCoreApi, +export const getReleaseGenres = Effect.fn("getReleaseGenres")(function* ( releaseId: string, releaseGenrePackageId: string, -): Promise { - const { objects } = await client.core.getObjects({ - objectIds: [releaseGenresFieldId(releaseId, releaseGenrePackageId)], - include: { content: true }, - }); - const object = objects[0]; - if (!object || object instanceof Error || !object.content) return []; - return parseReleaseGenresContent(object.content); -} +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(releaseGenresFieldId(releaseId, releaseGenrePackageId)); + return found._tag === "None" ? [] : parseReleaseGenresContent(found.value.content); +}); /** * Read a Recording's genre ids in order (primary first), or `[]` when no @@ -354,16 +375,10 @@ export async function getReleaseGenres( * reader because the Move `genres` view already returns `[]` for the absent * case. */ -export async function getRecordingGenres( - client: ClientWithCoreApi, +export const getRecordingGenres = Effect.fn("getRecordingGenres")(function* ( recordingId: string, recordingGenrePackageId: string, -): Promise { - const { objects } = await client.core.getObjects({ - objectIds: [recordingGenresFieldId(recordingId, recordingGenrePackageId)], - include: { content: true }, - }); - const object = objects[0]; - if (!object || object instanceof Error || !object.content) return []; - return parseRecordingGenresContent(object.content); -} +): Effect.fn.Return { + const found = yield* getOptionalObjectContent(recordingGenresFieldId(recordingId, recordingGenrePackageId)); + return found._tag === "None" ? [] : parseRecordingGenresContent(found.value.content); +}); diff --git a/packages/platform/src/party/client.ts b/packages/platform/src/party/client.ts index 3a7c959..9188443 100644 --- a/packages/platform/src/party/client.ts +++ b/packages/platform/src/party/client.ts @@ -3,9 +3,14 @@ // Party EXTENSIONS bound to one `PartyExtensionsDeployment`, wrapping a // `PartyosClient` for the Party core. Consumers reach this through -// `client.miso.party`. +// `client.miso.party`. Every read returns `Effect` (`R = never`) — the +// `SuiClient` service is provided once by `PartyosClient`/`MisoPlatformClient` +// and threaded through here, never re-provided per method. +import { Effect } from "effect"; +import type { Option } from "effect"; import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { SuiClient, type BcsDecodeError, type ObjectNotFoundError, type ObjectTypeMismatchError, type SuiRpcError } from "@misofm/effect"; import type { Party, PartyosClient } from "@misofm/partyos"; import type { PartyExtensionsDeployment } from "../deployments.ts"; import * as queries from "./queries.ts"; @@ -61,8 +66,8 @@ function bindModulePackage( * CTAs, platform links) this package owns. */ export class PartyPlatformClient { - #client: ClientWithCoreApi; #core: PartyosClient; + #layer: ReturnType; #profilePkg: string; #countryCodePkg: string; #languageCodePkg: string; @@ -77,8 +82,8 @@ export class PartyPlatformClient { #proLinkPkg: string; constructor(client: ClientWithCoreApi, core: PartyosClient, extensions: PartyExtensionsDeployment) { - this.#client = client; this.#core = core; + this.#layer = SuiClient.layer(client); this.#profilePkg = extensions.partyProfile; this.#countryCodePkg = extensions.countryCode; this.#languageCodePkg = extensions.languageCode; @@ -98,61 +103,68 @@ export class PartyPlatformClient { return this.#core; } + /** Provides the bound `SuiClient` layer, dropping `R` to `never`. */ + #provide(effect: Effect.Effect): Effect.Effect { + return effect.pipe(Effect.provide(this.#layer)); + } + // === Core (delegated to `PartyosClient`) === - async getPartyById(partyId: string): Promise { + getPartyById(partyId: string): Effect.Effect { return this.#core.getPartyById(partyId); } - async getPartiesByIds(partyIds: readonly string[]): Promise>> { + getPartiesByIds( + partyIds: readonly string[], + ): Effect.Effect>, ObjectTypeMismatchError | BcsDecodeError | SuiRpcError> { return this.#core.getPartiesByIds(partyIds); } derivePartyAdminCapId(partyId: string): string { return this.#core.derivePartyAdminCapId(partyId); } /** Group ids a party belongs to (member-side membership records). */ - async getMemberships(partyId: string): Promise { + getMemberships(partyId: string): Effect.Effect { return this.#core.getMemberships(partyId); } /** Member ids invited to a group but not yet accepted. */ - async getPendingInvites(groupId: string): Promise { + getPendingInvites(groupId: string): Effect.Effect { return this.#core.getPendingInvites(groupId); } /** Group ids that have invited this party but are awaiting its response. */ - async getPendingMemberships(partyId: string): Promise { + getPendingMemberships(partyId: string): Effect.Effect { return this.#core.getPendingMemberships(partyId); } /** Whether a party is a member of a group. */ - async isMember(memberId: string, groupId: string): Promise { + isMember(memberId: string, groupId: string): Effect.Effect { return this.#core.isMember(memberId, groupId); } // === Extension queries === - async getProfile(partyId: string): Promise { - return queries.getProfile(this.#client, partyId, this.#profilePkg); + getProfile(partyId: string): Effect.Effect, BcsDecodeError | SuiRpcError> { + return this.#provide(queries.getProfile(partyId, this.#profilePkg)); } - async getMedia(partyId: string): Promise { - return queries.getMedia(this.#client, partyId, this.#mediaPkg); + getMedia(partyId: string): Effect.Effect, BcsDecodeError | SuiRpcError> { + return this.#provide(queries.getMedia(partyId, this.#mediaPkg)); } /** The party's artist-type roles (display names). */ - async getRoles(partyId: string): Promise { - return queries.getRoles(this.#client, partyId, this.#rolesPkg); + getRoles(partyId: string): Effect.Effect { + return this.#provide(queries.getRoles(partyId, this.#rolesPkg)); } /** The party's free-form tags. */ - async getTags(partyId: string): Promise { - return queries.getTags(this.#client, partyId, this.#tagsPkg); + getTags(partyId: string): Effect.Effect { + return this.#provide(queries.getTags(partyId, this.#tagsPkg)); } /** The party's genre object ids. */ - async getGenres(partyId: string): Promise { - return queries.getGenres(this.#client, partyId, this.#partyGenrePkg); + getGenres(partyId: string): Effect.Effect { + return this.#provide(queries.getGenres(partyId, this.#partyGenrePkg)); } /** The party's ordered CTA list (position is priority). */ - async getCtas(partyId: string): Promise { - return queries.getCtas(this.#client, partyId, this.#ctaPkg); + getCtas(partyId: string): Effect.Effect { + return this.#provide(queries.getCtas(partyId, this.#ctaPkg)); } /** All external-platform links attached to the party (social, music, professional). */ - async getLinks(partyId: string): Promise { - return queries.getLinks(this.#client, partyId); + getLinks(partyId: string): Effect.Effect { + return this.#provide(queries.getLinks(partyId)); } // === Transaction builders (thunks; package ids bound from the client) === diff --git a/packages/platform/src/party/extensions/links.ts b/packages/platform/src/party/extensions/links.ts index c23b46b..3efec81 100644 --- a/packages/platform/src/party/extensions/links.ts +++ b/packages/platform/src/party/extensions/links.ts @@ -12,7 +12,8 @@ import * as platformLinkMod from "../../contracts/party_platform_link/party_platform_link.ts"; import type { TxThunk } from "@misofm/partyos"; -import type { PlatformKey, PlatformLink } from "../types.ts"; +import { PlatformLink } from "../types.ts"; +import type { PlatformKey } from "../types.ts"; /** Which payload package a platform's `Data` type + constructor live in. */ type PayloadPkgKind = "social" | "music" | "pro"; @@ -160,7 +161,7 @@ export function platformForDataType(dataType: string): PlatformKey | null { /** Builds a public `PlatformLink` from a platform key and its stored native identifier. */ export function buildLink(platform: PlatformKey, value: string): PlatformLink { - return { platform, value, url: PLATFORMS[platform].url(value) }; + return new PlatformLink({ platform, value, url: PLATFORMS[platform].url(value) }); } // === Bound builders (the client binds package ids, then exposes per-platform methods) === diff --git a/packages/platform/src/party/queries.ts b/packages/platform/src/party/queries.ts index 5f8a04d..68e978b 100644 --- a/packages/platform/src/party/queries.ts +++ b/packages/platform/src/party/queries.ts @@ -7,10 +7,17 @@ // in `@misofm/partyos` — this file only covers profile/media/roles/tags/genres/ // ctas/links. -import type { ClientWithCoreApi } from "@mysten/sui/client"; +import { Effect, Option, Stream } from "effect"; import { deriveDynamicFieldID } from "@mysten/sui/utils"; import { bcs } from "@mysten/sui/bcs"; -import { getObjectContent } from "@misofm/partyos"; +import { + decodeBcs, + getOptionalObjectContent, + listDynamicFields, + SuiClient, + type BcsDecodeError, + type SuiRpcError, +} from "@misofm/effect"; import { Profile as ProfileBcs, ProfileKey as ProfileKeyBcs } from "../contracts/party_profile/party_profile.ts"; import { Media as MediaBcs, MediaKey as MediaKeyBcs } from "../contracts/party_media/party_media.ts"; import { ArtistRole as ArtistRoleBcs, RolesKey as RolesKeyBcs } from "../contracts/party_roles/party_roles.ts"; @@ -19,115 +26,122 @@ import { GenresKey as GenresKeyBcs } from "../contracts/party_genre/party_genre. import { Cta as CtaBcs, CtasKey as CtasKeyBcs } from "../contracts/party_cta/party_cta.ts"; import { mapCtas, mapGenres, mapMedia, mapProfile, mapRoles, mapTags } from "./internal.ts"; import { buildLink, platformForDataType } from "./extensions/links.ts"; -import type { Cta, Media, PlatformLink, Profile } from "./types.ts"; +import { Cta, Media, PlatformLink, Profile } from "./types.ts"; // The profile is a dynamic field on the party UID under an empty `ProfileKey()` // (Move's implicit `dummy_field: bool` → one zero byte). const ProfileField = bcs.struct("Field", { id: bcs.Address, name: ProfileKeyBcs, value: ProfileBcs }); const PROFILE_KEY_BYTES = ProfileKeyBcs.serialize([false]).toBytes(); -/** Fetches a party's profile, or null if none is set. */ -export async function getProfile( - client: ClientWithCoreApi, +/** Fetches a party's profile; `Option.none()` if none is set. */ +export const getProfile = Effect.fn("getProfile")(function* ( partyId: string, partyProfilePackageId: string, -): Promise { +): Effect.fn.Return, BcsDecodeError | SuiRpcError, SuiClient> { const fieldId = deriveDynamicFieldID( partyId, `${partyProfilePackageId}::party_profile::ProfileKey`, PROFILE_KEY_BYTES, ); - const content = await getObjectContent(client, fieldId); - if (!content) return null; - return mapProfile(partyId, ProfileField.parse(content).value); -} + const contentOpt = yield* getOptionalObjectContent(fieldId); + if (Option.isNone(contentOpt)) return Option.none(); + const profile = yield* decodeBcs( + { parse: (bytes: Uint8Array) => mapProfile(partyId, ProfileField.parse(bytes).value) }, + Profile, + contentOpt.value.content, + { type: "Profile", objectId: fieldId }, + ); + return Option.some(profile); +}); // Media is a dynamic field on the party UID under an empty `MediaKey()` (same // implicit `dummy_field: bool` → one zero byte as the profile key). const MediaField = bcs.struct("Field", { id: bcs.Address, name: MediaKeyBcs, value: MediaBcs }); const MEDIA_KEY_BYTES = MediaKeyBcs.serialize([false]).toBytes(); -/** Fetches a party's media (avatar + header), or null if none is set. */ -export async function getMedia( - client: ClientWithCoreApi, +/** Fetches a party's media (avatar + header); `Option.none()` if none is set. */ +export const getMedia = Effect.fn("getMedia")(function* ( partyId: string, partyMediaPackageId: string, -): Promise { +): Effect.fn.Return, BcsDecodeError | SuiRpcError, SuiClient> { const fieldId = deriveDynamicFieldID( partyId, `${partyMediaPackageId}::party_media::MediaKey`, MEDIA_KEY_BYTES, ); - const content = await getObjectContent(client, fieldId); - if (!content) return null; - return mapMedia(partyId, MediaField.parse(content).value); -} + const contentOpt = yield* getOptionalObjectContent(fieldId); + if (Option.isNone(contentOpt)) return Option.none(); + const media = yield* decodeBcs( + { parse: (bytes: Uint8Array) => mapMedia(partyId, MediaField.parse(bytes).value) }, + Media, + contentOpt.value.content, + { type: "Media", objectId: fieldId }, + ); + return Option.some(media); +}); // === Collection extension reads (roles / tags / genres / ctas) === // // Each is a single dynamic field on the party UID under an empty positional key // (Move's implicit `dummy_field: bool` → one zero byte, same as the profile key). +// Absence keeps the pre-migration policy: an empty collection, not a failure. const ROLES_KEY_BYTES = RolesKeyBcs.serialize([false]).toBytes(); const RolesField = bcs.struct("Field", { id: bcs.Address, name: RolesKeyBcs, value: bcs.vector(ArtistRoleBcs) }); /** The party's artist-type roles (display names), or `[]` if none are set. */ -export async function getRoles( - client: ClientWithCoreApi, +export const getRoles = Effect.fn("getRoles")(function* ( partyId: string, partyRolesPackageId: string, -): Promise { +): Effect.fn.Return { const fieldId = deriveDynamicFieldID(partyId, `${partyRolesPackageId}::party_roles::RolesKey`, ROLES_KEY_BYTES); - const content = await getObjectContent(client, fieldId); - if (!content) return []; - return mapRoles(RolesField.parse(content).value); -} + const contentOpt = yield* getOptionalObjectContent(fieldId); + if (Option.isNone(contentOpt)) return []; + return mapRoles(RolesField.parse(contentOpt.value.content).value); +}); const TAGS_KEY_BYTES = TagsKeyBcs.serialize([false]).toBytes(); const TagsField = bcs.struct("Field", { id: bcs.Address, name: TagsKeyBcs, value: bcs.vector(bcs.string()) }); /** The party's free-form tags, or `[]` if none are set. */ -export async function getTags( - client: ClientWithCoreApi, +export const getTags = Effect.fn("getTags")(function* ( partyId: string, partyTagsPackageId: string, -): Promise { +): Effect.fn.Return { const fieldId = deriveDynamicFieldID(partyId, `${partyTagsPackageId}::party_tags::TagsKey`, TAGS_KEY_BYTES); - const content = await getObjectContent(client, fieldId); - if (!content) return []; - return mapTags(TagsField.parse(content).value); -} + const contentOpt = yield* getOptionalObjectContent(fieldId); + if (Option.isNone(contentOpt)) return []; + return mapTags(TagsField.parse(contentOpt.value.content).value); +}); const GENRES_KEY_BYTES = GenresKeyBcs.serialize([false]).toBytes(); const GenresField = bcs.struct("Field", { id: bcs.Address, name: GenresKeyBcs, value: bcs.vector(bcs.Address) }); /** The party's genre object ids, or `[]` if none are set. */ -export async function getGenres( - client: ClientWithCoreApi, +export const getGenres = Effect.fn("getGenres")(function* ( partyId: string, partyGenrePackageId: string, -): Promise { +): Effect.fn.Return { const fieldId = deriveDynamicFieldID(partyId, `${partyGenrePackageId}::party_genre::GenresKey`, GENRES_KEY_BYTES); - const content = await getObjectContent(client, fieldId); - if (!content) return []; - return mapGenres(GenresField.parse(content).value); -} + const contentOpt = yield* getOptionalObjectContent(fieldId); + if (Option.isNone(contentOpt)) return []; + return mapGenres(GenresField.parse(contentOpt.value.content).value); +}); const CTAS_KEY_BYTES = CtasKeyBcs.serialize([false]).toBytes(); // The CTA field's value is a bare `vector` (not a wrapper struct). const CtasField = bcs.struct("Field", { id: bcs.Address, name: CtasKeyBcs, value: bcs.vector(CtaBcs) }); /** The party's ordered CTA list (position is priority), or `[]` if none is set. */ -export async function getCtas( - client: ClientWithCoreApi, +export const getCtas = Effect.fn("getCtas")(function* ( partyId: string, partyCtaPackageId: string, -): Promise { +): Effect.fn.Return { const fieldId = deriveDynamicFieldID(partyId, `${partyCtaPackageId}::party_cta::CtasKey`, CTAS_KEY_BYTES); - const content = await getObjectContent(client, fieldId); - if (!content) return []; - return mapCtas(CtasField.parse(content).value); -} + const contentOpt = yield* getOptionalObjectContent(fieldId); + if (Option.isNone(contentOpt)) return []; + return mapCtas(CtasField.parse(contentOpt.value.content).value).map((c) => new Cta(c)); +}); // === Platform-link reads === // @@ -141,23 +155,21 @@ const PlatformLinkField = bcs.struct("Field", { id: bcs.Address, name: bcs.bool( const PLATFORM_LINK_KEY_RE = /::platform_link::PlatformLinkKey<(.+)>$/; /** All external-platform links attached to a party (social, music, professional). */ -export async function getLinks(client: ClientWithCoreApi, partyId: string): Promise { +export const getLinks = Effect.fn("getLinks")(function* ( + partyId: string, +): Effect.fn.Return { + const fields = Array.from(yield* Stream.runCollect(listDynamicFields(partyId))); const links: PlatformLink[] = []; - let cursor: string | null | undefined; - do { - const page = await client.core.listDynamicFields({ parentId: partyId, cursor: cursor ?? undefined }); - for (const f of page.dynamicFields) { - if (typeof f.name?.type !== "string") continue; - const match = f.name.type.match(PLATFORM_LINK_KEY_RE); - const dataType = match?.[1]; - if (!dataType) continue; - const platform = platformForDataType(dataType); - if (!platform) continue; - const content = await getObjectContent(client, f.fieldId); - if (!content) continue; - links.push(buildLink(platform, PlatformLinkField.parse(content).value)); - } - cursor = page.hasNextPage ? page.cursor : null; - } while (cursor); + for (const f of fields) { + if (typeof f.name?.type !== "string") continue; + const match = f.name.type.match(PLATFORM_LINK_KEY_RE); + const dataType = match?.[1]; + if (!dataType) continue; + const platform = platformForDataType(dataType); + if (!platform) continue; + const contentOpt = yield* getOptionalObjectContent(f.fieldId); + if (Option.isNone(contentOpt)) continue; + links.push(buildLink(platform, PlatformLinkField.parse(contentOpt.value.content).value)); + } return links; -} +}); diff --git a/packages/platform/src/party/types.ts b/packages/platform/src/party/types.ts index 1f33f67..ef121e9 100644 --- a/packages/platform/src/party/types.ts +++ b/packages/platform/src/party/types.ts @@ -6,79 +6,83 @@ // these. The Party object model itself (`Party`, `PartyKind`) is owned by // `@misofm/partyos` — import it from there. -export interface Profile { - partyId: string; - bioShort: string; - bioLong?: string; +import { Schema } from "effect"; + +export class Profile extends Schema.Class("@misofm/platform/Profile")({ + partyId: Schema.String, + bioShort: Schema.String, + bioLong: Schema.optional(Schema.String), /** ISO 3166-1 alpha-2 country code, e.g. "GB". */ - country?: string; + country: Schema.optional(Schema.String), /** ISO 639-1 language codes, e.g. ["en"]. */ - languages: string[]; -} + languages: Schema.Array(Schema.String), +}) {} /** * A party's imagery, stored on-chain as a single Walrus quilt. Individual images * (avatar, header, …) are quilt patches addressed by identifier off-chain — see * `mediaUrls` / `MEDIA_IDENTIFIERS` in `extensions/media`. */ -export interface Media { - partyId: string; +export class Media extends Schema.Class("@misofm/platform/Media")({ + partyId: Schema.String, /** Walrus quilt blob id (base64url) holding the party's images. */ - quiltId: string; -} + quiltId: Schema.String, +}) {} /** A single call-to-action on a party: a labeled external link. Position is priority. */ -export interface Cta { - label: string; - url: string; -} +export class Cta extends Schema.Class("@misofm/platform/Cta")({ + label: Schema.String, + url: Schema.String, +}) {} /** * A platform this SDK knows how to build a link for. One key per external * platform, spanning social (`x`, `instagram`, …), music (`spotify`, `bandcamp`, * …), and professional/industry (`website`, `patreon`, …) payloads. */ -export type PlatformKey = +export const PlatformKey = Schema.Literals([ // Social (party_social) - | "x" - | "instagram" - | "threads" - | "tiktok" - | "youtube" - | "discord" - | "telegram" - | "reddit" - | "twitch" - | "facebook" + "x", + "instagram", + "threads", + "tiktok", + "youtube", + "discord", + "telegram", + "reddit", + "twitch", + "facebook", // Music (party_music) - | "spotify" - | "bandcamp" - | "soundcloud" - | "appleMusic" - | "deezer" - | "tidal" - | "amazonMusic" - | "audiomack" + "spotify", + "bandcamp", + "soundcloud", + "appleMusic", + "deezer", + "tidal", + "amazonMusic", + "audiomack", // Professional / industry (party_pro_link) - | "website" - | "bookingPage" - | "managementPage" - | "publisherPage" - | "labelPage" - | "epk" - | "patreon" - | "substack" - | "kofi"; + "website", + "bookingPage", + "managementPage", + "publisherPage", + "labelPage", + "epk", + "patreon", + "substack", + "kofi", +]); +export type PlatformKey = typeof PlatformKey.Type; /** * One external-platform link attached to a party. Only the native identifier * (`value` — a handle, artist id, subdomain, or full URL) is stored on-chain; the * public `url` is rebuilt client-side from it. */ -export interface PlatformLink { - platform: PlatformKey; +export class PlatformLink extends Schema.Class("@misofm/platform/PlatformLink")({ + platform: PlatformKey, /** The platform-native identifier stored on-chain (handle / id / subdomain / URL). */ - value: string; + value: Schema.String, /** The public profile URL, rebuilt from `value`. */ - url: string; -} + url: Schema.String, +}) {} diff --git a/packages/platform/src/pressing.ts b/packages/platform/src/pressing.ts index da303b5..bc8d967 100644 --- a/packages/platform/src/pressing.ts +++ b/packages/platform/src/pressing.ts @@ -4,15 +4,23 @@ // Primary Record sales. `record` owns concrete Records and edition-scoped // Pressings; immutable `record_shop` owns per-currency Listings and payment. -import type { ClientWithCoreApi } from "@mysten/sui/client"; import { deriveObjectID, normalizeStructTag, normalizeSuiAddress, parseStructTag, } from "@mysten/sui/utils"; +import { Effect, Schema } from "effect"; +import { + assertObjectType, + decodeBcs, + getObjectsContent, + getOptionalObjectContent, + ObjectTypeMismatchError, + type SuiClient, +} from "@misofm/effect"; +import type { BcsDecodeError, SuiRpcError } from "@misofm/effect"; import type { TxThunk } from "./transactions.ts"; -import { isNotFound } from "./queries.ts"; import { asU64, type U64Input } from "./vault.ts"; import * as pressingContract from "./contracts/record/pressing.ts"; import * as recordContract from "./contracts/record/record.ts"; @@ -406,54 +414,45 @@ export function purchaseRecord(p: PurchaseRecordParams): TxThunk { }; } -export interface PressingView { - id: string; - releaseId: string; - edition: number; - supply: number; - maxSupply: number | null; - distributors: string[]; -} - -export interface ListingView { - id: string; - releaseId: string; - pressingId: string; - pricing: { kind: "fixed" | "floor"; amount: string }; - state: ListingSwitch; - currencyType: string; -} - -export interface RecordView { - id: string; - releaseId: string; - pressingId: string; - edition: number; - number: number; - purchaseCurrency: string; - purchasePrice: string; - purchasedBy: string; - purchasedTimestampMs: string; -} - -async function fetch( - client: ClientWithCoreApi, - objectId: string, -): Promise<{ content: Uint8Array; type: string } | null> { - try { - const { object } = await client.core.getObject({ - objectId, - include: { content: true }, - }); - if (!object) return null; - if (!object.content || !object.type) - throw new Error(`object ${objectId} has no BCS content or full type`); - return { content: object.content, type: object.type }; - } catch (error) { - if (isNotFound(error)) return null; - throw error; - } -} +/** One permanent record-production run derived from a release. */ +export class Pressing extends Schema.Class("@misofm/platform/Pressing")({ + id: Schema.String, + releaseId: Schema.String, + edition: Schema.Number, + supply: Schema.Number, + maxSupply: Schema.NullOr(Schema.Number), + distributors: Schema.Array(Schema.String), +}) {} + +/** One permanent currency offer derived from a Pressing. */ +export class Listing extends Schema.Class

("@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..7f5558e 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, 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,33 @@ 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 })), + // 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.tryPromise({ + try: () => Promise.resolve(onBatch(batchCurrencies, gasUsed)), + catch: (cause) => new SuiRpcError({ operation: "onBatch", cause }), + }) + : 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 +273,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/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 `