@misofm/platform
v0.45.0
Published
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 mo
Maintainers
Readme
@misofm/platform
The complete client SDK for the Miso platform layer on Sui: composed catalog,
artist, wallet, and receipt reads; the Party extensions (profile, media, roles,
tags, genres, CTAs, platform links); the record production line and sale of
copies; fail-closed Vault custody, raw Actions, and safe crank plugins; and
every first-party extension and generic royalty primitive built on top of the
@misofm/musicos object model and the @misofm/partyos Party identity model.
@misofm/platform is a sui-effect
extension: one Context.Service (Miso), built on sui-effect's Sui,
SuiGraphQL, and Tx, composed over the converted Musicos/Partyos
services, whose members are Effects with closed error unions, whose writes
are PTB fragments (Recipes) a consumer composes and submits once, and whose
Promise face is derived — never hand-written — by SuiExtension.fromService.
See node_modules/@unconfirmed/sui-effect/docs/extensions.md for the contract every
extension in this codebase follows, and "Migrating from 0.27"
below if you're coming from the hand-written MisoPlatformClient.
The boundary rule
@misofm/musicosis the object model. Everything Miso offers on top of it is platform.
Miso ships three SDK packages, and the package name tells you which promise you are holding:
| Package | Layer | Owns |
| ------------------- | ------------ | -------------------------------------------------------------------------------------------------------------- |
| @misofm/musicos | Object model | Composition, Recording, Release, Track — the open Move package anyone can build on, no permission required |
| @misofm/partyos | Party identity | Party, PartyAdminCap, and consent-based group membership — the open Move package Party identity is built on |
| @misofm/platform | Platform | The Party extensions (profile, media, roles, tags, genres, CTAs, platform links); work extensions; generic royalty-pool/routed-stake primitives; Actions; Vault; Record/Record Shop; product-specific publishing workflows |
A release is object model. Pressing a record off that release and selling it
is platform. So is deciding what to do with a freshly-minted work's share
supply — the object model only knows how to mint one. Likewise, a Party is
identity — who or what is being described — and lives in @misofm/partyos;
everything Miso attaches to that identity (a profile, media, roles, tags,
genres, CTAs, platform links) is an opinion about how to describe or route
value around it, not part of what the Party IS, so those extensions live in
platform alongside work extensions and every royalty primitive. Keeping the
boundary at the package line is what stops the open object models from
quietly growing a storefront (or an opinion about tokenomics).
Extensions add data to a work. Raw Actions accept an admin cap and remain
composable with either direct authority or a scoped Vault borrow. Three safe,
permissionless crank plugins borrow through their own witness: a shared
Vault<AdminCap> custodies the raw cap, while its owner holds a
VaultAdminCap<AdminCap>. A plugin borrows the cap and must return the exact
object in the same PTB. The SDK supports both vault authorities and legacy
address-owned admin caps explicitly; it never silently treats a legacy cap as a
vaulted one. New Vault IDs are derived from the shared VaultRegistry, the raw
cap ID, and its type; each VaultAdminCap ID is then derived from its Vault.
This package depends on @misofm/musicos and @misofm/partyos directly and
composes their converted Musicos/Partyos services internally, so
Miso.layer's requirement channel stays Sui | SuiGraphQL — never
Sui | SuiGraphQL | Musicos | Partyos — for a consumer that only asked for
Miso. Writes still compose in one PTB (the transaction-fragment pattern from
the Sui SDK building guide),
just crossing a package boundary.
Install
bun add @misofm/platform @unconfirmed/sui-effect effect @mysten/sui @mysten/bcsPeer dependencies: @unconfirmed/sui-effect@^0.1.0, effect@>=4.0.0-rc.112 <4.1,
@mysten/sui@^2.28, @mysten/bcs@^2.1.1. @misofm/musicos and
@misofm/partyos resolve transitively through this package, so applications
get exactly one object-model SDK, one Party-identity SDK, and one compatible
deployment map without installing either themselves. A custom deployment is
recursively frozen at Miso.layer build, without freezing the caller's
original object, so later caller mutation cannot retarget an existing client.
Engine sessions
A Recording's recording_engine_session::ExtensionKey dynamic field holds one
EngineSession: the bare u256 ID of the canonical Miso Engine Session V1
JSON, plus one Stem per source pairing the 32-byte SHA-256 of its canonical
PCM (the document's content identity) with the bare u256 ID of its FLAC
blob. The document carries no locators, so this field is where a client
resolves each source to bytes. Nothing is encrypted; there is no wrapper
document and no off-chain map.
getRecordingEngineSession reads the field in one request and returns the
session blob id and the stems table. setRecordingEngineSession and
unsetRecordingEngineSession are the cap-authorized PTB builders; the builder
sorts stems by digest, as recording_engine_session::new requires. Walrus id
conversions live in walrus-ids.
Audio masters use the same v1 shape: Audio.blob_id, EngineSession.blob_id,
and each stem's blob_id are bare u256 values. setRecordingMaster and
setRecordingEngineSession take those IDs directly and do not require an Ori
package ID. Ori remains a dependency for cover-art and streaming-transcode
constructors, which still wrap their own Walrus values.
The model
A release may have one Pressing per positive u16 edition. Editions start at 1
and must be created sequentially. Each Pressing owns its independent u32
Record-number sequence, current supply, a required positive immutable u32
maximum supply, and authorized distributor witness types.
New Pressing creation requires an explicit maxSupply from 1 to 4,294,967,295.
Atomic publication creates a fresh Release, so its Pressing must use edition 1.
The bundled testnet Record sales deployment remains unavailable until compatible
mandatory-cap Record and Record Shop packages are published and verified; its
previous package IDs are retained as non-executable deployment metadata.
Selling in a currency is a Listing<Currency>, one per currency, permanent, edited in
place rather than replaced. The Listing's enabled/disabled state is the sale switch;
the Pressing has no schedule state. Each Listing also records its gross proceeds from
completed sales, including accepted floor-price overpayments.
Everything is address math. The pressing's UID derives off its release's, each
listing's off the pressing's. The protocol's canonical ReleaseRegistry creates the
release; there is no pressing registry or mutable lookup pointer to follow, so
"where is it" is answered offline. A Pressing derives from (release, edition), a
Record from (pressing, number), and a Listing<Currency> from its Pressing under
the separate immutable Record Shop package. There are no Record Registry or Settings
singletons.
Usage
Effect
import { Effect } from "effect";
import { Sui, SuiCore, SuiGraphQL } from "@unconfirmed/sui-effect";
import { ObjectId } from "@unconfirmed/sui-effect";
import { Miso } from "@misofm/platform";
import { getMisoPlatformDeployment } from "@misofm/platform/deployments";
const deployment = getMisoPlatformDeployment("testnet");
const program = Effect.gen(function* () {
const miso = yield* Miso;
// The permissionless object-model and Party surfaces are part of the same service.
// Ids are branded: brand a trusted string with ObjectId.make, decode untrusted input.
const release = yield* miso.protocol.getReleaseById(ObjectId.make(releaseId));
const party = yield* miso.party.getPartyById(ObjectId.make(partyId));
// Read: run + one currency's offer, one round trip, no registry lookup.
const { pressing, listing } = yield* miso.getSale({ releaseId, edition, currencyType: USD_COIN_TYPE });
return { release, party, pressing, listing };
});
const { release, party, pressing, listing } = await program.pipe(
Effect.provide(Miso.layer(deployment)),
Effect.provide(Sui.layerNoDeps),
Effect.provide(SuiCore.layerGrpc({ network: "testnet", baseUrl: "https://..." })),
Effect.provide(SuiGraphQL.layerConfig), // or SuiGraphQL.layerUnavailable with no endpoint
Effect.runPromise,
);Inside a Script or an existing sui-effect-based program, just yield* Miso
after providing Miso.layer(deployment) (or layerConfig) over the Sui | SuiGraphQL
your program already has.
A read that can fail closed (a missing release, an unavailable Record sales
deployment) fails with one of this SDK's tagged errors — see
Errors — so recover with Effect.catchTag/Effect.catchTags
instead of inspecting a thrown message:
const releaseOrNull = miso.protocol
.getReleaseById(ObjectId.make(releaseId))
.pipe(Effect.catchTag("ObjectNotFound", () => Effect.succeed(null)));Promise
import { SuiGrpcClient } from "@mysten/sui/grpc";
import { Transaction } from "@mysten/sui/transactions";
import { miso } from "@misofm/platform/client";
const client = new SuiGrpcClient({ network: "testnet", baseUrl }).$extend(miso());
// The face keeps a member's argument types, and every namespace is nested:
// client.miso.protocol.*, client.miso.party.*, client.miso.read.*.
const release = await client.miso.protocol.getReleaseById(ObjectId.make(releaseId));
const party = await client.miso.party.getPartyById(ObjectId.make(partyId));
const { pressing, listing } = await client.miso.getSale({ releaseId, edition, currencyType: USD_COIN_TYPE });
// Write: a Recipe, so it composes with protocol/party calls in the same PTB.
// `tx` builders never touch the network — no Effect, no Promise involved.
const tx = new Transaction();
tx.add(
client.miso.tx.purchaseRecord({
releaseId,
edition,
currencyType: USD_COIN_TYPE,
paymentAmount: listing!.pricing.amount,
expectedPricing: listing!.pricing,
recipient: buyer,
}),
);
await client.miso.dispose();miso() registers warm — tx/ids/call/bcs/vault/deployment are
synchronous members a consumer reads the moment it registers, and Miso.layer
touches no network at build for a client on mainnet or testnet (their
chain identifiers are in sui-effect's built-in table). On devnet,
localnet, or a custom network, warm needs a chain identifier from
somewhere: pass chainId explicitly, or pass a deployment whose own
chainIdentifier names it (options.chainId ?? options.deployment?.chainIdentifier)
— a custom deployment for your own network is enough on its own, nothing
else to repeat. Without either, client.$extend(miso()) throws
synchronously, naming the network. A network/deployment mismatch also
throws synchronously here (warm builds the layer inside register,
so Miso.layer's own MisoNetworkMismatchError/MisoChainIdentifierMismatchError
surface immediately rather than on first call) — register lazily
(SuiExtension.fromService(Miso, { name: "miso", layer: Miso.layer(deployment) }),
no warm) if you want that check deferred to the first call instead, and
await client.miso.$ready() once before reading a synchronous member.
miso() on a network with no bundled manifest and no explicit deployment
rejects typed (MisoPlatformDeploymentInvalidError), not with an unhandled
defect.
dispose() is not final: it releases the runtime the registration built, but
the registration itself is unchanged. Because miso() registers warm, the
next use after dispose() re-runs the warm build synchronously (since
@unconfirmed/sui-effect 0.1.1), so a synchronous member read straight after
dispose() is real again. Only a lazy, non-warm registration goes cold and
throws ExtensionNotReady on a synchronous read until an Effect member is
awaited or $ready() runs. Dispose when the consumer is actually done, not
between calls.
Verified package and singleton IDs are bundled in
MISO_PLATFORM_DEPLOYMENTS.testnet. miso()/Miso.layer select that
verified map from the client's network by default; pass an explicit
deployment for a custom or unbundled network. The bundled Testnet
deployment includes both verified immutable sales package IDs; custom
deployments can still mark sales unavailable explicitly, and every sales
builder/reader fails closed (typed RecordSalesUnavailableError) whenever
either package is unavailable.
Holding the ids yourself, outside the service? The bare APIs take
recordPackageId/recordShopPackageId explicitly and need only Sui:
import { purchaseRecord, getSale } from "@misofm/platform/pressing";Party and the generated contract tree
Party identity (the Party object, PartyAdminCap, group membership) is
owned by @misofm/partyos; this package only owns the extensions attached to
it. miso.party (MisoPartyService) delegates every core method
(getPartyById, getMemberships, …) straight to the converted Partyos
service, and merges in this package's own party EXTENSION reads and 25 tx
fragments (9 from Partyos.tx, 16 first-party). It is assembled from Sui
and Partyos — never constructed directly by a consumer; get it from
Miso/client.miso:
const profile = await client.miso.party.getProfile(partyId); // Option<Profile>Every generated Move package on this side of the boundary — the Party
extensions, work extensions, royalty/routed-stake primitives, Record/Record
Shop, Vault, and every Action/plugin — is reachable through the curated
contracts barrel (@misofm/platform → contracts.*, or
@misofm/platform/contracts directly) or, for a module the barrel doesn't
curate, the raw generated file itself. The Party core's generated bindings
live in @misofm/partyos/contracts instead:
import { record } from "@misofm/platform/contracts";
import { Record } from "@misofm/platform/contracts/record/record";High-level platform reads
@misofm/platform/read turns protocol, pressing, Party, credits, cover, and wallet
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.
Version 0.44 targets the public /v1 API namespace. MisoConfig.apiBaseUrl is
the versioned API base (for example, https://api.testnet.miso.fm/v1), and
artist profiles now return canonical party avatar URLs at
/v1/parties/:partyId/avatar. Authenticated platform mutations use the same
canonical paths while retaining exact legacy /platform/... challenge targets
for migration clients.
Every read here takes the resolved MisoConfig (package ids, not a transport)
and declares Sui/SuiGraphQL in its Requirements — createMisoClient
(read/client.ts) still bundles the transport, config, and the client.miso
Promise face the way it always bundled a transport; provide its layer
(Miso | Sui | SuiCore | SuiGraphQL) once, at the boundary:
import { Effect } from "effect";
import {
createMisoClient,
getDiscoverShelf,
getReleaseDetail,
getOwnedRecords,
} from "@misofm/platform/read";
const miso = createMisoClient({ network: "testnet" });
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(miso.layer)));
// Or skip the standalone `read/*` functions and reach for the bound Miso
// service/Promise face directly — `miso.client.miso.read.*` is the same
// views, config already captured:
const sameDiscover = await miso.client.miso.read.getDiscoverShelf();The package root also exposes the same functions under the read namespace:
import { read } from "@misofm/platform";
const miso = read.createMisoClient({ network: "testnet" });
const artist = await Effect.runPromise(read.getArtistProfile(partyId, miso.config).pipe(Effect.provide(miso.layer)));Authenticated platform mutations
@misofm/platform/auth implements Miso's Enoki + Sui personal-message authorization
protocol without owning session state or private credentials. It asks the API
for a short-lived, method/path-bound challenge, validates the response, signs
the exact bytes with the caller's Sui signer, and sends the authenticated
mutation.
import { authenticatedFetch } from "@misofm/platform/auth";
await authenticatedFetch(
"https://api.testnet.miso.fm/platform/usernames/alice",
{
method: "PUT",
body: JSON.stringify({}),
headers: { "Content-Type": "application/json" },
auth: {
token: enokiOidcToken,
address: suiAddress,
signer: await enokiFlow.getKeypair({ network: "testnet" }),
network: "testnet",
},
},
);The SDK is only a client and shared wire contract. The API remains the security boundary: it verifies Enoki membership, challenge freshness, the recovered Sui address, and the exact authorized route on every protected request.
Payment
listing::purchase takes a bare Balance<Currency>, and purchaseRecord sources it with
tx.balance() — which draws from the buyer's address balance first and falls back
to coin objects only if it must. When the address balance covers the price, that is a
single balance::redeem_funds and no coin object is minted, touched, or destroyed,
leaving the sale free of owned-object contention.
Never hand-pick coin objects for a payment. That road shows a buyer their $1,000 and then refuses to spend a cent of it, because a coin listing cannot see money that lives in the address balance.
Purchases always set useGasCoin: false: the gas coin may belong to a sponsor. Buyers
also pass the exact expected Fixed or Floor pricing variant and value, protecting
them from stale pricing-mode changes as well as amount changes.
Vault fund settlement
settleAndDistributeReleaseRevenue, settleCompositionRoyaltyPool, and
settleRecordingRoyaltyPool each invoke the fixed plugin crank
(redeem_all_and_distribute / redeem_all_and_deposit) with the framework
AccumulatorRoot (0xacc); the plugin reads the settled snapshot on chain and
redeems all of it, and exposes no amount argument. A zero snapshot, or a pool
with no registered stake, is an on-chain no-op, so many of these calls can
share one PTB without an already-cranked item aborting the batch. The
object-argument forms redeemAllAndDistributeReleaseRevenue,
redeemAllAndDepositCompositionRoyaltyPool, and
redeemAllAndDepositRecordingRoyaltyPool take an already-resolved object
argument instead of an ID. There is no caller-chosen-amount redemption on
chain any more; settledFundsValue remains available for Party wallets.
Party-wallet monetary builders are similarly composable:
receivePartyWalletBalance, redeemPartyWalletBalance, and
settlePartyWalletBalance return the PTB Balance<Currency> result. Pass that
result directly to another Move call, or convert it with coin::from_balance
only when an owned Coin is required. Every returned Balance must be consumed in
the same PTB.
Publishing (transactions.ts, share.ts, release-graph.ts)
@misofm/musicos's createComposition/createRecording mint a work and hand
back its by-value parts (the object, its admin cap, its freshly-minted share
Balance) without dispersing, sharing, or transferring anything. This package
supplies the opinionated finish on top:
import { miso } from "@misofm/platform/client";
const client = new SuiGrpcClient({ network: "testnet", baseUrl }).$extend(miso());
// Mints the composition's share supply, disperses it to shareRecipients as
// address balances, publishes (shares) the composition, and transfers the
// CompositionAdminCap to adminAddress — createComposition → finalizeComposition
// in one PTB. A Recipe, not a Promise: apply it to a Transaction, or tx.add() it.
const recipe = client.miso.tx.publishComposition({
title: "Song Title",
royaltyRateBps: 1000,
shareType: "0x...::share::Share",
shareCurrencyId: "0x...",
shareTreasuryCapId: "0x...",
shareRecipients: [{ address: ownerAddress, value: 100_000_000_000_000 }],
adminAddress: ownerAddress,
});client.miso.tx.publishRecording and publishCompositionAndRecording
follow the same shape (the latter atomically, borrow-before-share, in one PTB —
see @misofm/musicos's README for why the ordering is load-bearing).
The protocol, immutable Record and Record Shop packages, minato, and core
ReleaseRegistry address all come from the deployment selected by the Sui
client's network. Record sales have no Record Registry or Settings singleton.
disperseShares targets the deployed minato::disperse<C> and sorts paired
payouts into its canonical run encoding. Amounts must be positive u64 values;
recipient text must be nonempty and a valid Sui address. Duplicate recipients
are preserved, and balance::destroy_zero enforces the exact allocation total.
The public helper signature is unchanged.
For custom PTBs, the bare primitives (disperseShares, finalizeComposition,
finalizeRecording) and the whole-graph orchestrator are exported standalone:
import { publishReleaseGraph } from "@misofm/platform";
// Every composition and recording, optional royalty pools, tracks, and
// the release — with the release id derived ON-CHAIN — in one atomic PTB.
const recipe = publishReleaseGraph({
compositions: [
{
shareType,
shareCurrencyId,
shareTreasuryCapId,
title: "Song",
royaltyRateBps: 1000,
shareRecipients,
adminAddress,
},
],
recordings: [
{
shareType,
shareCurrencyId,
shareTreasuryCapId,
compositionShareType,
parentCompositionIndex: 0,
shareRecipients,
adminAddress,
},
],
release: {
title: "Album",
nonce: "42",
adminAddress,
releaseRegistryId: "0x...",
tracks: [{ recordingIndex: 0, splitBps: 10000 }],
},
misoPackageId: "0x...",
minatoPackageId: "0x...",
});Atomic catalog publication (publication.ts)
publishAtomicCatalog owns the semantic publication transaction. Given
pre-initialized share currencies, it creates every new Party, Composition,
Recording, Track, and Release; applies all declared data extensions; composes
raw-cap Actions through direct custody or a scoped Vault borrow; installs only
the permissionless royalty/revenue crank plugins; opens the Pressing and
Listings; shares the new objects; and delivers only the selected direct admin
cap or VaultAdminCap. The
entire catalog stage is one PTB, so none of it can land partially.
Share allocation is explicit at the SDK boundary. Omitting
shareDistribution preserves the existing "balance" behavior. Setting it to
"stake" converts the minted Balance<Share> into one address-owned
Stake<Share> per shareRecipients entry. When the work also declares a
royaltyPool, "stake" is required: the builder creates the pool unshared,
registers each fresh stake, shares the pool, and then transfers the registered
stakes, so the pool never starts without stakers. A royaltyPool combined
with "balance" (or the default) is rejected before PTB construction, because
a pool with no registered stakes accumulates revenue that the first holder to
register later would claim in full. The lower level createShareStake,
createShareStakes, registerShareStake, newCompositionRoyaltyPool,
newRecordingRoyaltyPool, and shareRoyaltyPool builders expose each step
separately for custom PTBs.
For a fresh Recording whose parent Composition owns a protocol royalty cut,
recordings[].routedStake redeems that exact cut into a derived
RoutedStake, registers it with the Recording's royalty pool, and shares it
for permissionless sweeping. The Recording and parent Composition must both
declare royalty pools in the same currency, and the parent must use Vault
custody:
recordings: [{
// ...fresh parent, share currency, custody, and royaltyPool...
routedStake: true,
}]The SDK derives the exact composition_routed_stake::create_stake value from
the protocol's fixed share supply and the fresh parent's royaltyRateBps; it
does not permit zero or partial routing. Both works must use "stake" share
distribution so the Recording pool has its complete supply registered and the
parent destination pool is operable from the first sweep. Atomic publication
supports rates from 1 to 9999 BPS because it always allocates a non-zero
Recording creator remainder; lower-level routed-stake builders remain
available for the Move layer's 100% composition-cut case.
import { assertAtomicPublicationBounds } from "@misofm/platform/publication";
const publication = {
parties,
compositions, // includes initialized share Currency + TreasuryCap ids
recordings,
release,
pressing,
};
// Pure local assembly: fail before publishing any share package if the final
// PTB exceeds the SDK's command/input safety limits or has an invalid graph
// (deployment is added by publishCatalog itself, below).
assertAtomicPublicationBounds({ ...publication, deployment: client.miso.deployment });
// One Tx.run, submitted exactly once — no executor. `publishAtomicCatalog`
// (the fragment) and `parseAtomicPublicationResult` (now `Executed`-shaped)
// remain exported standalone from `@misofm/platform/publication` for a
// caller composing this PTB with other fragments before running it.
const result = await client.miso.publishCatalog(publication, { signer });Fresh raw PartyAdminCap, CompositionAdminCap, RecordingAdminCap, and ReleaseAdminCap values never leave the PTB when Vault custody is selected. Only the Composition royalty-pool, Recording royalty-pool, and Release revenue plugins are installable, while each new Vault is still owned. Party-wallet and Composition routed-stake operations remain raw Actions. Atomic publication consumes the returned routed stake by registering and sharing it; lower-level callers retain explicit control over those lifecycle steps. Plugin witness construction remains inside the SDK bindings.
Share packages necessarily precede this stage: publish at most five per PTB,
then initialize their currencies, then submit the atomic catalog PTB — see
"Share Currency Provisioning" below. Tx.run holds one sender lock per
address, so batches serialize under one signer rather than running
concurrently through an executor (the accepted 0.28 behaviour change).
Share Currency Provisioning (share.ts)
Every composition and recording is backed by its own fixed-supply share
currency: an independently published share package (bytecode template
embedded as SHARE_TEMPLATE, initializer patched via patchInitializer).
Publish and initialize are necessarily two transactions:
import { Effect } from "effect";
// Sequential (one currency, two Tx.run's — a moveCall target needs the
// package's id, and a package published in the same PTB has none until it
// executes):
const currency = await client.miso.createShareCurrency({ name: "Song Shares", description: "…" }, { signer });
// → { packageId, currencyId, shareType, treasuryCapId, gasUsed: bigint }
// Batched (many currencies). Both hold one sender lock per Tx.run — what a
// ParallelTransactionExecutor ran concurrently now serializes per batch
// under that lock, the accepted 0.28 behaviour change.
const { packageIds } = await client.miso.publishShareCurrencies(10, { signer });
const { currencies } = await client.miso.initializeShareCurrencies(
packageIds,
(pkg) => ({ name: "…", description: "…" }),
{ signer, onBatch: (batch, gasUsed) => Effect.sync(() => console.log(`initialized ${batch.length} currencies, ${gasUsed} MIST`)) },
);Tx.run(recipe, { signer }) is what every submit-on-behalf member here
builds on — journal, sender lock, epoch expiration, and reconcile apply, so a
stuck submission is a typed SubmissionUnknown carrying the signed bytes, not
a silent hang. onBatch (an Effect, run inline before the next batch)
reports each succeeded batch as it lands, so a caller can persist progress
before the whole call settles — if a later batch fails, the ones already
reported through onBatch do not need re-initializing.
Extensions
An extension attaches data to a protocol work through that work's cap-gated
uid_mut hook. The work stays a protocol object; the opinion hanging off it is
ours.
Credits (credits.ts)
Contributor credits pair a party with a display name and one or more domain-specific roles, attached to a work as a dynamic field and gated by the work's admin cap. Three role vocabularies:
- Composition (writing, 1–5 roles, no level):
Adapter,Arranger,Composer,Lyricist,Songwriter,Translator, or{ type: "Custom", name }. - Recording (production/performance, 1–10 roles): 28 leveled roles (
Producer,Vocalist,Engineer, …) each with an optional senioritylevel(Lead,Featured,Executive, …), plus{ type: "Instrumentalist", instrument, level? },{ type: "Custom", name, level? }, and the unleveledArtistsAndRepertoire/Copyist. - Release (top-line billing, exactly one role):
"Primary"or"Featured".
Writers validate client-side, mirroring the Move aborts: display name non-empty and ≤200 UTF-8 bytes; role counts within the caps above; no duplicate roles.
import {
attachCompositionCredit,
attachRecordingCredit,
addReleaseCredit,
addRecordingPrimaryArtist,
addRecordingFeaturedArtist,
getCompositionCredits,
getRecordingCredits,
getReleaseCredits,
} from "@misofm/platform";
const thunk = attachRecordingCredit({
recordingId: "0x...",
recordingAdminCapId: "0x...",
partyId: "0x...",
displayName: "Jane Doe",
roles: [
{ type: "Vocalist", level: "Lead" },
{ type: "Instrumentalist", instrument: "Guitar" },
],
recordingShareType: "0x...::share::Share",
compositionShareType: "0x...::share::Share",
recordingCreditsPackageId: "0x...",
misoCreditPackageId: "0x...",
});
// Designate an already-credited party (same params minus displayName/roles/misoCreditPackageId):
addRecordingPrimaryArtist({
recordingId,
recordingAdminCapId,
partyId,
recordingShareType,
compositionShareType,
recordingCreditsPackageId,
});
// Reads return null when no credits field is attached.
const credits = await getCompositionCredits(
client,
compositionId,
compositionCreditsPackageId,
);
// CreditView[]: { partyId, displayName, roles: string[] } — e.g. "Producer (Lead)", "Instrumentalist: Guitar"
const rc = await getRecordingCredits(
client,
recordingId,
recordingCreditsPackageId,
);
// { credits: CreditView[], primaryArtistIds: string[], featuredArtistIds: string[] }attachCompositionCredit takes compositionId/compositionAdminCapId/compositionShareType/compositionCreditsPackageId;
addReleaseCredit takes releaseId/releaseAdminCapId and a single role.
A recording is Recording<RecordingShare, CompositionShare> — the recording's
OWN share type comes first, its parent composition's second. The recording
writers take both as separate named params for that reason; passing them in the
wrong order still typechecks (both are string) and resolves to the wrong
on-chain type.
Cover art (cover.ts)
A release's cover is a Walrus blob referenced on-chain via ori::WalrusData,
attached under the release_cover_art extension:
import { setReleaseCover, getReleaseCover } from "@misofm/platform";
const thunk = setReleaseCover({
releaseId: "0x...",
releaseAdminCapId: "0x...",
stillBlobId: "987654321", // Walrus blob id as u256 (decimal string or bigint)
animatedBlobId: null, // optional animated cover
coverArtPackageId: "0x...",
releaseCoverArtPackageId: "0x...",
oriPackageId: "0x...",
});
const cover = await getReleaseCover(
client,
releaseId,
releaseCoverArtPackageId,
);
// ReleaseCoverView | null: { still, animated } as normalized Walrus refs
// ({ kind: "blob", blobId } | { kind: "quiltPatch", quiltId, version, startIndex, endIndex })Vault operations (vault.ts)
vault.ts contains composable PTB builders for custody and plugin flows:
invokeWithAdminCap safely sequences borrow_as_admin → Move call → put_back, and
custodyNewAdminCap shares the Vault while transferring only its owner-held
VaultAdminCap through the Vault module. deriveVaultId and
deriveVaultAdminCapId discover both canonical object IDs without an RPC lookup.
withdrawVaultCapability and restoreVaultCapability operate on the permanent
Vault shell; withdrawal requires every plugin to have been removed. Plugin
installers construct their witnesses inside their Move package; callers supply no
witness.
Version 0.42 targets verified share currencies with 100,000,000 whole shares
and six decimals (100_000_000_000_000 base units). Composition and Recording
royalty-pool constructors now require shareCurrency, the matching coin-registry
Currency<Share> object. Atomic catalog publication passes each work’s
shareCurrencyId automatically. These constructors require the corresponding
new contract generation; publishing this SDK does not activate a deployment.
It also builds Composition/Recording royalty-pool initialization and cranks,
fixed Release settlement plus raw-admin amount composition, Party wallet
Actions, and the full Composition routed-stake Action lifecycle. Receive flows
take exact object references and construct the required
vector<Receiving<Coin<Currency>>> in the PTB.
The bundled Testnet deployment sets operations.status to "unavailable":
the recorded package uses the previous Vault custody layout/API. Its identities
remain under operations.legacy for provenance and do not select current
transaction builders or object readers. Publish a fresh Vault package and registry,
republish dependent plugins, then supply a verified complete operations deployment.
client.miso.vault remains an object whose members fail with
OperationsUnavailableError while unavailable. Custom deployment validation checks
canonical, pairwise-distinct IDs; callers own their ABI compatibility and provenance.
The generated Vault API uses withdrawVaultedCap, restoreVaultedCap, and
vaultedCapId. Named arguments use cap for the VaultAdminCap and vaultedCap
for the custodied capability. Stored fields are vaulted_cap_id and vaulted_cap;
events use vaulted_cap_id for the custodied object and cap_id for the
administrator. The high-level deriveVaultId input is now vaultedCapId.
BCS field order is unchanged by this rename, so a decoder cannot detect a stale
schema from bytes alone. Match events to the exact deployed package/type generation;
never interpret the old event's cap_id as the current administrator ID.
Migrating from 0.16
Version 0.17 is a breaking deployment-safety release. Replace flat Vault,
Action, and plugin package fields with the discriminated operations union.
The Release revenue plugin crank is now fixed: call
redeemAllAndDistribute(vault, release, accumulatorRoot) with no amount.
(Since 0.40 the explicit-amount redeemAndDistribute Action no longer exists
on chain either; see "Migrating from 0.39".)
Migrating from 0.39
0.40 tracks the redeem-all-only revenue Actions and plugins (misofm/audit#1):
the on-chain redeem_and_distribute(value) and redeem_and_deposit(value)
functions are gone, so redeemAndDistributeReleaseRevenue,
redeemAndDepositCompositionRoyaltyPool, and
redeemAndDepositRecordingRoyaltyPool are removed along with their generated
bindings (Miso.contracts.*.redeemAndDistribute / .redeemAndDeposit).
settleCompositionRoyaltyPool and settleRecordingRoyaltyPool keep their
parameters but now emit one redeem_all_and_deposit command (with the
accumulator root) instead of settled_funds_value + redeem_and_deposit;
use redeemAllAndDepositCompositionRoyaltyPool /
redeemAllAndDepositRecordingRoyaltyPool when you already hold the object
argument. Generated bindings gain redeemAllAndDeposit on the two pool
Actions and the two pool plugins.
Migrating event decoders to v1
The v1 package generation makes event purposes explicit. Generated bindings and
platformEventParsers no longer expose AudioIngestedEvent,
VaultCapabilityReturnedEvent, VaultCapabilityBorrowedByPluginEvent, or
VaultCapabilityBorrowedByAdminEvent, and the plugins parser group is gone
because first-party plugins emit no plugin-owned events. Audio values, audio
master reads, and all audio transaction builders remain available with bare
Walrus blob IDs. Plugin install,
uninstall, authorization, borrowing, and Action execution remain available as
operations; only the retired event codecs are removed.
Royalty pools emit RoyaltyPoolCreatedEvent once at construction with parent
identity and initial accounting state. Sharing is silent: the generated
RoyaltyPoolSharedEvent codec and primitives.royaltyPool.poolShared parser
are removed. Use primitives.royaltyPool.poolCreated for pool discovery;
registration and deposit events report any changes made before sharing.
Positive direct pool deposits emit RoyaltyDepositedEvent<Share, Currency>;
positive accumulator settlements emit only
RoyaltyPoolFundsSettledEvent<Share, Currency>. These are mutually exclusive
pool accounting receipts. The settlement event now includes
cumulative_reward_per_share_before and carry_before immediately after
value, followed by its existing after-state fields. Fold both event kinds
to reconstruct total pool inflows. Action receipts and framework funds effects
provide additional context; do not count them again as new pool deposits.
Routed stakes likewise retain RoutedStakeCreatedEvent at construction with
parent identity and initial stake value. Sharing is silent; the generated
RoutedStakeSharedEvent codec and primitives.routedStake.shared parser are
removed. Use primitives.routedStake.created for discovery and the retained
registration, unstaking and restaking events for subsequent state changes,
including changes made before sharing.
Vaults retain VaultCreatedEvent with registry, capability and administrator
identity plus initial state. Sharing is silent: VaultSharedEvent, both parser
aliases (primitives.vault.shared and .vaultShared), and the Miso facade export
are removed. Use primitives.vault.created for discovery; authorization and
capability custody changes retain their own events, including before sharing.
Pressings retain PressingCreatedEvent with their initial edition, supply limit
and capability provenance. Sharing is silent: PressingSharedEvent, the
products.pressing.shared parser and Miso facade export are removed. Use
products.pressing.created for discovery; distributor changes and purchases
retain their own events, including changes made before sharing.
Listings retain ListingCreatedEvent with initial pricing, enabled state and
capability provenance. Sharing is silent: ListingSharedEvent, the
products.listing.shared parser and Miso facade export are removed. Use
products.listing.created for discovery; pricing, availability and purchase
events report subsequent changes, including changes made before sharing.
Record destruction emits only record_id, release_id, pressing_id,
edition, and number. It does not transfer funds or refund a purchase.
Join the Record ID to its purchase event for original currency, price, purchaser,
and purchase time; those immutable fields also remain on a live Record.
destroy remains nongeneric.
Master changes use MasterSetEvent<RecordingShare, CompositionShare> and
MasterUnsetEvent<RecordingShare, CompositionShare>. Their BCS fields are
unchanged: the share identities are carried by the full Move event type.
Phantom arguments do not appear in payload bytes. Preserve the full type tag
alongside decoded data and use the query service's supported type filters.
Route the events that still exist by their owning purpose:
| Purpose | Parser path |
|---|---|
| Vault plugin authorization or revocation | primitives.vault.pluginAuthorized / pluginRevoked |
| Composition or Recording royalty deposit | actions.compositionRoyaltyPool.coinsDeposited / .fundsDeposited; actions.recordingRoyaltyPool.coinsDeposited / .fundsDeposited |
| Royalty pool coin recovery | primitives.royaltyPool.coinsRecovered |
| Release revenue receipts | actions.releaseRevenueDistributor.coinsReceived, .fundsRedeemed, .trackRevenueDistributed, and .revenueDistributed |
The v1 schema keeps the positive financial Action and extension event codecs. The earlier redeem-all migration also removed eight plugin operation wrappers; use the Action and generic Vault paths above for those receipts. Filter by the full on-chain event type, including package and module, because an event name alone cannot distinguish historical wrappers from current Action or Vault events.
Some successful writes now intentionally produce no event without changing
the event schemas. Zero royalty claims, equal metadata replacement, and
clearing an absent metadata slot are silent. A full metadata value change and
an initial explicit empty declaration still emit. When a release distribution
has total_input == 0, its per-track distribution rows and completion summary
are suppressed. ReleaseCoinsReceivedEvent still emits when nonempty
zero-valued coins are consumed. Existing RoyaltyClaimedEvent codecs continue
to decode historical zero-amount claims; this migration does not alter the
business filtering of royalty history.
Removing the last genre now reports field_exists_after: false on its
GenreRemovedEvent. Explicit bulk genresCleared remains available with the
same schema; clear_cause: 1 is historical-only for consumers that need to
recognize old cascade events.
Adopt this registry only after the redeem-all rollout and immutable republish, with deployment IDs updated together; the current old deployed IDs are not compatible with this interface. This is a breaking decoder change for the v1 immutable package generation. For historical events, use the SDK and event schemas generated from the originating package generation; the v1 registry intentionally does not decode retired event types.
Migrating from 0.27
0.28 replaces the hand-written MisoPlatformClient class and its
@misofm/effect foundation with the sui-effect extension this README
describes throughout. See sui-effect's own
"Migrating from @misofm/effect"
table for the mechanical Sui-primitive renames (getObjectContent →
sui.getObject, SuiRpcError → TransportError, …); this section is only
what changed in THIS package's own surface.
Moved onto Miso (client.miso.*, unchanged names): getPressing/
getListing/getRecord/getSale, ids.*, tx.*, call/bcs, vault,
createShareCurrency/publishShareCurrencies/initializeShareCurrencies,
protocol, party. All were methods/getters on the MisoPlatformClient
instance before; they are Miso service members now, still reachable the
same way through client.miso after $extend(miso()).
Removed:
| Before | After |
| --- | --- |
| MisoPlatformClient, misoPlatform(config), MisoPlatformConfig | deleted; Miso + miso() (SuiExtension.fromService) |
| PartyPlatformClient, new PartyosClient(client, ...) | deleted; client.miso.party (MisoPartyService), assembled internally |
| await client.miso.ready() | kept, deprecated: does no work now (Miso.layer's exact-chain check runs at layer build, not first use) — see docs/CONVERSION.md |
| client.miso.validateChainIdentifier() | deleted; MisoChainIdentifierMismatchError surfaces from Miso.layer build instead |
| MisoClientNotReadyError | deleted — no more platform-specific readiness gate; sui-effect's own ExtensionNotReady covers a synchronous member read before warm/$ready() |
| client.miso.vault returning undefined when unavailable | always an object; every member throws OperationsUnavailableError — test deployment.operations.status, not if (client.miso.vault) |
| TxThunk | Recipe (export type TxThunk = Recipe kept, one minor, deprecated) |
| executeViaExecutor(executor, ...thunks), @misofm/platform/execute | deleted; Tx.run(recipe, { signer }) — one submission under the sender lock, journal, epoch expiration, reconcile; no executor |
| createShareCurrency(signer, params) | client.miso.createShareCurrency(params, { signer }) — signer is a sui-effect Signer (Signer.fromKeypair/Signer.fromSdkSigner); gasUsed is bigint |
| publishShareCurrencies(executor, initializerAddress, count), initializeShareCurrencies(executor, signerAddress, ids, metaOf) | client.miso.publishShareCurrencies(count, { signer }), client.miso.initializeShareCurrencies(ids, metaOf, { signer, onBatch? }) — batches serialize under one sender lock instead of running concurrently through an executor |
| parseAtomicPublicationResult(p, result: PlatformExecResult) | (p, executed: Executed) — allCreatedByType/createdByExactType are now Executed.created(type)/createdWhere(pred); gasUsed is bigint; digest is a branded Digest |
| getPressing/getListing/getRecord/getSale failing ObjectTypeMismatchError \| BcsDecodeError \| SuiRpcError | DecodeError \| ObjectUnavailable \| TransportError (plus RecordSalesUnavailableError when this deployment has no Record sales) — one decode error, not-found stays null |
| getSale/batch reads on getObjectsContent (silently drops errored ids) | sui.getObjects, a Result per id — each read decides per item: getSale fails the whole read on a genuine ObjectUnavailable; a soft read like resolveGenreNames skips it |
| getBalance returning decimal strings | Balance with bigint; read/* JSON-safe views still return strings — only the low tier changed |
| SuiGraphQL from @misofm/effect | SuiGraphQL from @unconfirmed/sui-effect (GraphQLUnavailableError → GraphQLUnavailable; SuiRpcError { operation } → TransportError { method }) |
| a chainIdentifier-less registration on devnet/localnet/a custom network | miso({ chainId }) required, or registration throws synchronously (warm) naming the network |
@misofm/effect is gone from dependencies; @mysten/sui, effect, and
@unconfirmed/sui-effect are peers instead.
Extension types
import type {
CreditView,
RecordingCreditsView,
CompositionRole,
RecordingRole,
RecordingRoleLevel,
RecordingLeveledRoleType,
ReleaseRole,
ReleaseCoverView,
CoverImageRef,
} from "@misofm/platform";RecordingLeveledRoleType is the union of the 28 recording role base names that
carry an optional RecordingRoleLevel (Producer, Vocalist, Engineer,
Conductor, …) — the leveled arm of RecordingRole. The other arms
(Instrumentalist, Custom, and the unleveled ArtistsAndRepertoire /
Copyist) are spelled out separately in RecordingRole.
Errors
Every typed failure is a Schema.TaggedError and declares outcome: "applied"
| "not_applied" | "unknown" (SuiError.outcome/Script.exitCode read it),
recoverable with Effect.catchTag/Effect.catchTags instead of message
sniffing. sui-effect's own taxonomy (DecodeError, TransportError,
ObjectNotFound/ObjectDeleted/ObjectUnavailable, GraphQLUnavailable,
ExecutionFailed, and the Tx.run union: BuildError, SimulationFailed,
PolicyDenied, SigningError, NotApplied, SubmissionUnknown,
JournalError, UnexpectedEffects) is re-exported from @unconfirmed/sui-effect so this
is the only import a consumer needs:
import {
ObjectNotFound,
ReleaseNotFoundError,
RecordSalesUnavailableError,
OperationsUnavailableError,
MisoChainIdentifierMismatchError,
MisoNetworkMismatchError,
} from "@misofm/platform/errors";Platform-specific tags, all outcome: "not_applied" (nothing was submitted):
| Error | Fields | Raised by |
| --- | --- | --- |
| RecordSalesUnavailableError | reason | requireRecordSalesDeployment, getPressing/getListing/getRecord/getSale, ids.*/tx.* sales members on a legacy or unconfigured deployment |
| OperationsUnavailableError | reason | requireOperationsDeployment, every client.miso.vault member, ids.vault/ids.vaultAdminCap |
| MisoPlatformDeploymentInvalidError | message | assertMisoPlatformDeployment / normalizeMisoPlatformDeployment / Miso.layerConfig on an unbundled MISO_NETWORK |
| MisoNetworkMismatchError | clientNetwork, deploymentNetwork | Miso.layer build — synchronously at $extend(miso()) under a warm registration (mainnet/testnet), or rejecting the first call for a lazy one |
| MisoChainIdentifierMismatchError | actual, expected | Miso.layer build, same timing as MisoNetworkMismatchError above — the exact-ledger check the predecessor's ready() used to perform at first use |
| 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 |
import { Effect } from "effect";
const release = miso.protocol
.getReleaseById(releaseId)
.pipe(
Effect.catchTags({
ObjectNotFound: () => Effect.succeed(null),
TransportError: (e) => Effect.die(e), // transport failure: not recoverable here
}),
);Layout
src/
Miso.ts the Miso service: MisoService assembly (reads, ids/tx/call/bcs/vault, read.*, events), layer/layerConfig/layerTest
client.ts miso(): the SuiExtension.fromService registration (client.miso.*); MisoClient/MisoOptions
deployments.ts fail-closed deployment schema (MisoPlatformDeployment, PartyExtensionsDeployment) and address injection point
packages.ts MisoPlatformPackageBindings: extensions/primitives/party generated calls bound to one deployment
events.ts platformEventParsers: work/Party extensions, Actions, products, and primitive event decoders
royalty.ts generic royalty-pool / stake / routed-stake derive helpers and PTB builders
pressing.ts standalone: builders, readers, and the id derivations (Sui-based reads; Miso.tx/getPressing etc. bind these)
queries.ts shared read plumbing (isNotFound, re-exported from @misofm/musicos)
transactions.ts the Recipe (TxThunk) contract + the opinionated publish flow (disperse/finalize/publish*)
release-graph.ts whole release graph in one PTB (publishReleaseGraph)
publication.ts atomic catalog publication (publishAtomicCatalog, parseAtomicPublicationResult); Miso.publishCatalog wraps it in Tx.run
share.ts share-currency provisioning on Tx.run (createShareCurrency, batched variants); Miso.createShareCurrency etc. bind these to Sui
share-template.ts embedded `share` package bytecode
credits.ts EXTENSION: contributor credits + the three role vocabularies
cover.ts EXTENSION: release cover art (Walrus blob via ori)
genre.ts EXTENSION: release/recording genre vocabulary
release-extensions.ts EXTENSION: release kind, description, DSP links
recording-extensions.ts EXTENSION: recording advisory, language, master, engine session, streaming transcode
party/ Party EXTENSIONS: profile, media, roles, tags, genres, CTAs, links (party/client.ts's makeMisoParty assembles MisoPartyService over the converted Partyos) — also `@misofm/platform/party`
read/ high-level catalog, artist, wallet, and receipt views (config.ts, client.ts's createMisoClient); Miso.read.* binds these to one deployment
vault.ts Vault authority, plugin, event, and receiving-coin builders; Miso.vault gates these with OperationsUnavailableError
internal.ts private helpers (the 0x1::option moveCall targets, immutableSnapshot) — NOT exported
contracts.ts barrel re-exporting the curated generated bindings as `contracts`
contracts/ GENERATED — do not edit by hand; also reachable raw via `@misofm/platform/contracts/*`Codegen
Bindings are generated from the live Move source, so the typed layer cannot drift from the on-chain ABI:
bun run codegen # reads sui-codegen.config.ts → src/contracts/This package's generated tree (src/contracts/) covers every Move package this
side of the boundary rule — the ten party_* extensions plus party_wallet,
work extensions, generic royalty/routed-stake primitives, Record and Record
Shop, Vault, generic share, miso_pay, and platform_link primitives, and every Action/plugin package. The Party core (partyos)
generates into @misofm/partyos, and the object-model core
(composition/recording/release/track) generates into @misofm/musicos;
this package depends on both for those bindings — adding either core here to
save an import is how the split these packages exist to enforce gets undone.
The root sui-codegen.config.ts (one level up from this package) is the
source of truth for exactly which Move packages generate into which package's
tree, and where their sibling checkouts resolve from; read it rather than
relying on an enumeration here going stale.
bun run codegen # reads ../../sui-codegen.config.ts → src/contracts/For an isolated checkout, copy the source trees it lists and set
MISO_SDK_CODEGEN_SOURCE_ROOT to their common parent. The codegen config reads
only from that copy, avoiding writes to a developer's live source tree.
Dependency on @misofm/musicos and @misofm/partyos
@misofm/musicos and @misofm/partyos are regular runtime dependencies of
this package (workspace dependencies in this monorepo, resolved to published
version ranges on publish), not peers. Miso.layer composes their own
Musicos.layer/Partyos.layer internally (Layer.provided inside Miso's
own layer), so client.miso.protocol (the converted Musicos service) and
client.miso.party (MisoPartyService, Partyos core plus this package's
own party extensions) are always present — no readiness gate, no !
non-null assertion needed on either. Miso's own requirement channel stays
Sui | SuiGraphQL, never Sui | SuiGraphQL | Musicos | Partyos.
@mysten/sui itself stays a peer dependency here, so an application resolves
exactly one Sui SDK across all three packages regardless of which
object-model or Party-identity version @misofm/platform pins.
bun add @misofm/platform@misofm/musicos resolves automatically as a transitive dependency; there is
no separate install step and no peer version for consumers to reconcile.
Testing
Tests run on @unconfirmed/sui-effect/testing's in-memory fake — no network, no real
signer. layerTest(script) (from @unconfirmed/sui-effect/testing) provides Sui/
SuiCore/SuiCoreFake for Effect-level tests against Miso.layer/
layerTest; a $extend-level test builds SuiCoreFake.layer(script) and
calls fake.client.$extend(miso({ deployment })) directly, exactly the way a
consumer writes it:
import { Effect } from "effect";
import { SuiCoreFake } from "@unconfirmed/sui-effect/testing";
import { miso } from "@misofm/platform/client";
const fake = await Effect.runPromise(
Effect.provide(SuiCoreFake, SuiCoreFake.layer({ network: "testnet", chainId, objects: [...] })),
);
const client = fake.client.$extend(miso({ deployment: testDeployment }));
const pressing = await client.miso.getPressing(pressingId);
await client.miso.dispose();FakeOutcome.succeed(...)/FakeOutcome.failWith(...)/FakeOutcome.transportError(...)
script Tx.run's execute; TestClock (from effect/testing) plus
Journal.layerMemory (from @unconfirmed/sui-effect/tx) drive a submission through a
resubmit/reconcile schedule without waiting on real time (see
tests/share.test.ts's initializeShareCurrencies retry case). Miso.layer/
layerTest need SuiGraphQL too now that read.* joins the service — compose
SuiGraphQL.layerUnavailable (a test that never reaches a GraphQL-backed
member) or SuiGraphQL.layer(stubbedClient) alongside layerTest/
SuiCoreFake.layer the same way @misofm/musicos's own GraphQL tests do.
tests/deployments.test.ts covers the pure deployments.ts validators
(requireRecordSalesDeployment/requireOperationsDeployment, the frozen
bundled manifest); tests/client.test.ts is only the derived facade
(registration, nested namespaces, a fragment composed with a sibling
fragment, a submit-on-behalf member, rejection identity, the
network-mismatch path); tests/Miso.test.ts is the service at the Effect
level (the exact-chain check, layerConfig, layerTest).
