@misofm/api-client 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misofm/api-client",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Typed client and response contract for the Miso API read layer. The one definition of what a Miso read returns.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -2,17 +2,26 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { describe, expect, test } from "bun:test";
5
- import { cacheBuster, createMisoApiClient, MisoApiContractError, MisoApiError, READ_CACHE_CLASS } from "./client.ts";
5
+ import {
6
+ cacheBuster,
7
+ createMisoApiClient,
8
+ MisoApiContractError,
9
+ MisoApiError,
10
+ READ_CACHE_CLASS,
11
+ } from "./client.ts";
6
12
 
7
13
  /** A fetch that records the URL it was called with and replays a canned response. */
8
14
  function stubFetch(response: { status?: number; body?: unknown }) {
9
15
  const calls: string[] = [];
10
16
  const fetch = (async (input: string | URL) => {
11
17
  calls.push(String(input));
12
- return new Response(response.body === undefined ? "" : JSON.stringify(response.body), {
13
- status: response.status ?? 200,
14
- headers: { "Content-Type": "application/json" },
15
- });
18
+ return new Response(
19
+ response.body === undefined ? "" : JSON.stringify(response.body),
20
+ {
21
+ status: response.status ?? 200,
22
+ headers: { "Content-Type": "application/json" },
23
+ },
24
+ );
16
25
  }) as unknown as typeof globalThis.fetch;
17
26
  return { fetch, calls };
18
27
  }
@@ -35,56 +44,98 @@ describe("URL construction", () => {
35
44
 
36
45
  test("tolerates a trailing slash on the base URL", async () => {
37
46
  const { fetch, calls } = stubFetch({ body: balance });
38
- await createMisoApiClient({ baseUrl: `${BASE}/`, fetch }).getBalance("0xabc");
47
+ await createMisoApiClient({ baseUrl: `${BASE}/`, fetch }).getBalance(
48
+ "0xabc",
49
+ );
39
50
  expect(calls[0]).toBe(`${BASE}/read/v1/wallets/0xabc/balance`);
40
51
  });
41
52
 
42
53
  test("omits empty query params rather than sending them blank", async () => {
43
54
  const { fetch, calls } = stubFetch({ body: balance });
44
- await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc", undefined);
55
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
56
+ "0xabc",
57
+ undefined,
58
+ );
45
59
  expect(calls[0]).not.toContain("coinType");
46
60
  });
47
61
 
48
62
  test("sends an explicit coin type when given one", async () => {
49
63
  const { fetch, calls } = stubFetch({ body: balance });
50
- await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc", "0x2::sui::SUI");
64
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
65
+ "0xabc",
66
+ "0x2::sui::SUI",
67
+ );
51
68
  expect(calls[0]).toContain("coinType=0x2%3A%3Asui%3A%3ASUI");
52
69
  });
53
70
 
54
71
  test("joins the artist include list into one param", async () => {
55
72
  const { fetch, calls } = stubFetch({ status: 404 });
56
- await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xp", { include: ["roles", "tags"] });
57
- expect(calls[0]).toContain("include=roles%2Ctags");
73
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xp", {
74
+ include: ["roles", "tags", "featured"],
75
+ });
76
+ expect(calls[0]).toContain("include=roles%2Ctags%2Cfeatured");
77
+ });
78
+
79
+ test("joins catalog relationship expansions into one param", async () => {
80
+ const { fetch, calls } = stubFetch({ status: 404 });
81
+ const client = createMisoApiClient({ baseUrl: BASE, fetch });
82
+ await client.getPressing("0xp", { include: ["trackCredits"] });
83
+ await client.getRelease("0xl", { include: ["trackCredits"] });
84
+ await client.getRecordAlbum("0xr", {
85
+ include: ["release", "trackCredits"],
86
+ });
87
+ expect(calls[0]).toContain("include=trackCredits");
88
+ expect(calls[1]).toContain("include=trackCredits");
89
+ expect(calls[2]).toContain("include=release%2CtrackCredits");
58
90
  });
59
91
 
60
92
  test("a custom prefix is honored (self-hosted / direct-to-service)", async () => {
61
93
  const { fetch, calls } = stubFetch({ body: balance });
62
- await createMisoApiClient({ baseUrl: BASE, fetch, prefix: "/v1" }).getBalance("0xabc");
94
+ await createMisoApiClient({
95
+ baseUrl: BASE,
96
+ fetch,
97
+ prefix: "/v1",
98
+ }).getBalance("0xabc");
63
99
  expect(calls[0]).toBe(`${BASE}/v1/wallets/0xabc/balance`);
64
100
  });
65
101
  });
66
102
 
67
103
  describe("not-found handling", () => {
68
104
  test("a superseded pressing is null, not an error", async () => {
69
- const { fetch } = stubFetch({ status: 404, body: { error: { code: "not_found", message: "gone" } } });
70
- expect(await createMisoApiClient({ baseUrl: BASE, fetch }).getPressing("0xdead")).toBeNull();
105
+ const { fetch } = stubFetch({
106
+ status: 404,
107
+ body: { error: { code: "not_found", message: "gone" } },
108
+ });
109
+ expect(
110
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getPressing("0xdead"),
111
+ ).toBeNull();
71
112
  });
72
113
 
73
114
  test("an unknown artist is null", async () => {
74
- const { fetch } = stubFetch({ status: 404, body: { error: { code: "not_found", message: "gone" } } });
75
- expect(await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xdead")).toBeNull();
115
+ const { fetch } = stubFetch({
116
+ status: 404,
117
+ body: { error: { code: "not_found", message: "gone" } },
118
+ });
119
+ expect(
120
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xdead"),
121
+ ).toBeNull();
76
122
  });
77
123
 
78
124
  test("an empty id list short-circuits without a request", async () => {
79
125
  const { fetch, calls } = stubFetch({ body: [] });
80
- expect(await createMisoApiClient({ baseUrl: BASE, fetch }).getArtists([])).toEqual([]);
126
+ expect(
127
+ await createMisoApiClient({ baseUrl: BASE, fetch }).getArtists([]),
128
+ ).toEqual([]);
81
129
  expect(calls).toHaveLength(0);
82
130
  });
83
131
  });
84
132
 
85
133
  describe("error handling", () => {
86
134
  test("surfaces the server's error code and message", async () => {
87
- const { fetch } = stubFetch({ status: 429, body: { error: { code: "rate-limited", message: "Too many requests." } } });
135
+ const { fetch } = stubFetch({
136
+ status: 429,
137
+ body: { error: { code: "rate-limited", message: "Too many requests." } },
138
+ });
88
139
  const api = createMisoApiClient({ baseUrl: BASE, fetch });
89
140
  await expect(api.getDiscover()).rejects.toThrow(MisoApiError);
90
141
  try {
@@ -109,17 +160,24 @@ describe("error handling", () => {
109
160
  });
110
161
 
111
162
  test("a 404 on an endpoint that must return a body is an error, not null", async () => {
112
- const { fetch } = stubFetch({ status: 404, body: { error: { code: "not_found", message: "gone" } } });
113
- await expect(createMisoApiClient({ baseUrl: BASE, fetch }).getWalletRecords("0xabc")).rejects.toThrow(MisoApiError);
163
+ const { fetch } = stubFetch({
164
+ status: 404,
165
+ body: { error: { code: "not_found", message: "gone" } },
166
+ });
167
+ await expect(
168
+ createMisoApiClient({ baseUrl: BASE, fetch }).getWalletRecords("0xabc"),
169
+ ).rejects.toThrow(MisoApiError);
114
170
  });
115
171
  });
116
172
 
117
173
  describe("contract validation", () => {
118
174
  test("a response missing a required field fails loudly at the boundary", async () => {
119
- const { fetch } = stubFetch({ body: { address: "0xabc", coinType: "0x2::sui::SUI" } }); // no balance
120
- await expect(createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc")).rejects.toThrow(
121
- MisoApiContractError,
122
- );
175
+ const { fetch } = stubFetch({
176
+ body: { address: "0xabc", coinType: "0x2::sui::SUI" },
177
+ }); // no balance
178
+ await expect(
179
+ createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc"),
180
+ ).rejects.toThrow(MisoApiContractError);
123
181
  });
124
182
 
125
183
  test("a u64 sent as a NUMBER is rejected — the precision bug this contract exists to prevent", async () => {
@@ -131,7 +189,9 @@ describe("contract validation", () => {
131
189
  });
132
190
 
133
191
  test("the contract error names the field and points at version skew", async () => {
134
- const { fetch } = stubFetch({ body: { ...balance, balance: "not-a-number" } });
192
+ const { fetch } = stubFetch({
193
+ body: { ...balance, balance: "not-a-number" },
194
+ });
135
195
  try {
136
196
  await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
137
197
  throw new Error("should have thrown");
@@ -143,7 +203,10 @@ describe("contract validation", () => {
143
203
 
144
204
  test("a valid response parses through to typed data", async () => {
145
205
  const { fetch } = stubFetch({ body: balance });
146
- const result = await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
206
+ const result = await createMisoApiClient({
207
+ baseUrl: BASE,
208
+ fetch,
209
+ }).getBalance("0xabc");
147
210
  expect(result).toEqual(balance);
148
211
  });
149
212
  });
@@ -151,7 +214,12 @@ describe("contract validation", () => {
151
214
  describe("READ_CACHE_CLASS", () => {
152
215
  test("every wallet-scoped read is private", () => {
153
216
  for (const [method, cls] of Object.entries(READ_CACHE_CLASS)) {
154
- if (method.startsWith("getWallet") || method.startsWith("owns") || method === "getBalance" || method === "getWork") {
217
+ if (
218
+ method.startsWith("getWallet") ||
219
+ method.startsWith("owns") ||
220
+ method === "getBalance" ||
221
+ method === "getWork"
222
+ ) {
155
223
  expect(cls).toBe("private");
156
224
  }
157
225
  }
@@ -177,7 +245,11 @@ describe("cache buster", () => {
177
245
 
178
246
  test("a supplied version rides on every read as ?v=", async () => {
179
247
  const { fetch, calls } = stubFetch({ body: balance });
180
- const api = createMisoApiClient({ baseUrl: BASE, fetch, version: () => "1699999999" });
248
+ const api = createMisoApiClient({
249
+ baseUrl: BASE,
250
+ fetch,
251
+ version: () => "1699999999",
252
+ });
181
253
  await api.getBalance("0xabc");
182
254
  expect(calls[0]).toContain("v=1699999999");
183
255
  });
@@ -196,6 +268,8 @@ describe("cache buster", () => {
196
268
  test("cacheBuster is per-SECOND, so a burst after one write shares an entry", () => {
197
269
  // Millisecond granularity would mint a fresh cache entry per read.
198
270
  expect(cacheBuster(1_700_000_000_123)).toBe(cacheBuster(1_700_000_000_900));
199
- expect(cacheBuster(1_700_000_000_000)).not.toBe(cacheBuster(1_700_000_001_000));
271
+ expect(cacheBuster(1_700_000_000_000)).not.toBe(
272
+ cacheBuster(1_700_000_001_000),
273
+ );
200
274
  });
201
275
  });
package/src/client.ts CHANGED
@@ -23,8 +23,8 @@ import type {
23
23
  Balance,
24
24
  DiscoverShelf,
25
25
  DropPreview,
26
- FeaturedRelease,
27
26
  OwnedParty,
27
+ PendingMembership,
28
28
  OwnedRecord,
29
29
  OwnedWork,
30
30
  Ownership,
@@ -33,7 +33,6 @@ import type {
33
33
  PurchaseReceipt,
34
34
  RecordAlbum,
35
35
  ReleaseDetail,
36
- ReleaseTrackCredits,
37
36
  WorkDetail,
38
37
  } from "./types.ts";
39
38
 
@@ -102,7 +101,8 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
102
101
  function url(path: string, query: Record<string, QueryValue> = {}): string {
103
102
  const u = new URL(`${base}${prefix}${path}`);
104
103
  for (const [k, v] of Object.entries(query)) {
105
- if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v));
104
+ if (v !== undefined && v !== null && v !== "")
105
+ u.searchParams.set(k, String(v));
106
106
  }
107
107
  const v = options.version?.();
108
108
  if (v) u.searchParams.set("v", v);
@@ -116,7 +116,9 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
116
116
  opts: { nullOn404?: boolean } = {},
117
117
  ): Promise<T | null> {
118
118
  const target = url(path, query);
119
- const res = await doFetch(target, { headers: { Accept: "application/json" } });
119
+ const res = await doFetch(target, {
120
+ headers: { Accept: "application/json" },
121
+ });
120
122
 
121
123
  if (res.status === 404 && opts.nullOn404) return null;
122
124
 
@@ -124,52 +126,98 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
124
126
  const body = await res.json().catch(() => null);
125
127
  const parsed = s.apiErrorSchema.safeParse(body);
126
128
  throw parsed.success
127
- ? new MisoApiError(res.status, parsed.data.error.code, parsed.data.error.message)
128
- : new MisoApiError(res.status, "unknown", `Request to ${path} failed (${res.status})`);
129
+ ? new MisoApiError(
130
+ res.status,
131
+ parsed.data.error.code,
132
+ parsed.data.error.message,
133
+ )
134
+ : new MisoApiError(
135
+ res.status,
136
+ "unknown",
137
+ `Request to ${path} failed (${res.status})`,
138
+ );
129
139
  }
130
140
 
131
141
  const json = await res.json().catch(() => {
132
- throw new MisoApiError(res.status, "bad_response", `Response from ${path} was not JSON`);
142
+ throw new MisoApiError(
143
+ res.status,
144
+ "bad_response",
145
+ `Response from ${path} was not JSON`,
146
+ );
133
147
  });
134
148
 
135
149
  const parsed = schema.safeParse(json);
136
- if (!parsed.success) throw new MisoApiContractError(path, parsed.error.issues);
150
+ if (!parsed.success)
151
+ throw new MisoApiContractError(path, parsed.error.issues);
137
152
  return parsed.data;
138
153
  }
139
154
 
140
155
  /** Same as `request`, for endpoints that always return a body. */
141
- async function required<T>(schema: z.ZodType<T>, path: string, query?: Record<string, QueryValue>): Promise<T> {
156
+ async function required<T>(
157
+ schema: z.ZodType<T>,
158
+ path: string,
159
+ query?: Record<string, QueryValue>,
160
+ ): Promise<T> {
142
161
  return (await request(schema, path, query)) as T;
143
162
  }
144
163
 
145
164
  return {
146
165
  // ── Catalog ─────────────────────────────────────────────────────────────
147
166
  /** The records currently on sale. */
148
- getDiscover: (): Promise<DiscoverShelf> => required(s.discoverShelfSchema, "/discover"),
167
+ getDiscover: (): Promise<DiscoverShelf> =>
168
+ required(s.discoverShelfSchema, "/discover"),
149
169
 
150
170
  /** A pressing and everything its buy page renders. `null` when there is no such pressing. */
151
- getPressing: (pressingId: string): Promise<PressingDetail | null> =>
152
- request(s.pressingDetailSchema, `/pressings/${pressingId}`, {}, { nullOn404: true }),
171
+ getPressing: (
172
+ pressingId: string,
173
+ opts: { include?: readonly "trackCredits"[] } = {},
174
+ ): Promise<PressingDetail | null> =>
175
+ request(
176
+ s.pressingDetailSchema,
177
+ `/pressings/${pressingId}`,
178
+ { include: opts.include?.join(",") },
179
+ { nullOn404: true },
180
+ ),
153
181
 
154
182
  /** A release with its cover, credits, and resolved tracklist. */
155
- getRelease: (releaseId: string): Promise<ReleaseDetail | null> =>
156
- request(s.releaseDetailSchema, `/releases/${releaseId}`, {}, { nullOn404: true }),
157
-
158
- /** Per-track credits for a release, keyed by recording id. */
159
- getReleaseTrackCredits: (releaseId: string): Promise<ReleaseTrackCredits> =>
160
- required(s.releaseTrackCreditsSchema, `/releases/${releaseId}/credits`),
183
+ getRelease: (
184
+ releaseId: string,
185
+ opts: { include?: readonly "trackCredits"[] } = {},
186
+ ): Promise<ReleaseDetail | null> =>
187
+ request(
188
+ s.releaseDetailSchema,
189
+ `/releases/${releaseId}`,
190
+ { include: opts.include?.join(",") },
191
+ { nullOn404: true },
192
+ ),
161
193
 
162
194
  /** A record's parent release. Immutable for the life of the record. */
163
- getRecordAlbum: (recordId: string): Promise<RecordAlbum | null> =>
164
- request(s.recordAlbumSchema, `/records/${recordId}/album`, {}, { nullOn404: true }),
195
+ getRecordAlbum: (
196
+ recordId: string,
197
+ opts: { include?: readonly ("release" | "trackCredits")[] } = {},
198
+ ): Promise<RecordAlbum | null> =>
199
+ request(
200
+ s.recordAlbumSchema,
201
+ `/records/${recordId}/album`,
202
+ { include: opts.include?.join(",") },
203
+ { nullOn404: true },
204
+ ),
165
205
 
166
206
  /** Confirmation preview for a pasted pressing id. `null` when it isn't a pressing. */
167
207
  getDropPreview: (dropId: string): Promise<DropPreview | null> =>
168
- request(s.dropPreviewSchema, `/drops/${dropId}/preview`, {}, { nullOn404: true }),
208
+ request(
209
+ s.dropPreviewSchema,
210
+ `/drops/${dropId}/preview`,
211
+ {},
212
+ { nullOn404: true },
213
+ ),
169
214
 
170
215
  // ── Artist ──────────────────────────────────────────────────────────────
171
- /** An artist page. `include` adds the owner-editor fields (roles, tags). */
172
- getArtist: (partyId: string, opts: { include?: readonly ("roles" | "tags")[] } = {}): Promise<ArtistProfile | null> =>
216
+ /** An artist plus optional relationship expansions. */
217
+ getArtist: (
218
+ partyId: string,
219
+ opts: { include?: readonly ("roles" | "tags" | "featured")[] } = {},
220
+ ): Promise<ArtistProfile | null> =>
173
221
  request(
174
222
  s.artistProfileSchema,
175
223
  `/artists/${partyId}`,
@@ -177,10 +225,6 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
177
225
  { nullOn404: true },
178
226
  ),
179
227
 
180
- /** The party's pinned pressing, resolved. `null` when nothing is pinned. */
181
- getArtistFeatured: (partyId: string): Promise<FeaturedRelease | null> =>
182
- request(s.featuredReleaseSchema.nullable(), `/artists/${partyId}/featured`) as Promise<FeaturedRelease | null>,
183
-
184
228
  /** Name + kind for many parties at once. */
185
229
  getArtists: (ids: readonly string[]): Promise<PartySummary[]> =>
186
230
  ids.length === 0
@@ -196,6 +240,10 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
196
240
  getWalletParties: (address: string): Promise<OwnedParty[]> =>
197
241
  required(s.ownedPartiesSchema, `/wallets/${address}/parties`),
198
242
 
243
+ /** Group invitations awaiting acceptance by this wallet's controlled parties. */
244
+ getPendingMemberships: (address: string): Promise<PendingMembership[]> =>
245
+ required(s.pendingMembershipsSchema, `/wallets/${address}/pending-memberships`),
246
+
199
247
  /** The works this wallet administers, keyed by admin cap. */
200
248
  getWalletWorks: (address: string): Promise<OwnedWork[]> =>
201
249
  required(s.ownedWorksSchema, `/wallets/${address}/works`),
@@ -210,16 +258,28 @@ export function createMisoApiClient(options: MisoApiClientOptions) {
210
258
 
211
259
  /** Whether the wallet holds this party's admin cap. Carries the cap id. */
212
260
  ownsParty: (address: string, partyId: string): Promise<Ownership> =>
213
- required(s.ownershipSchema, `/wallets/${address}/owns`, { party: partyId }),
261
+ required(s.ownershipSchema, `/wallets/${address}/owns`, {
262
+ party: partyId,
263
+ }),
214
264
 
215
265
  /** Whether the wallet owns this record. */
216
266
  ownsRecord: (address: string, recordId: string): Promise<Ownership> =>
217
- required(s.ownershipSchema, `/wallets/${address}/owns`, { record: recordId }),
267
+ required(s.ownershipSchema, `/wallets/${address}/owns`, {
268
+ record: recordId,
269
+ }),
218
270
 
219
271
  // ── Receipts ────────────────────────────────────────────────────────────
220
272
  /** What one record sale was, re-derived from its transaction. */
221
- getReceipt: (pressingId: string, txDigest: string): Promise<PurchaseReceipt | null> =>
222
- request(s.purchaseReceiptSchema, `/receipts/${pressingId}/${txDigest}`, {}, { nullOn404: true }),
273
+ getReceipt: (
274
+ pressingId: string,
275
+ txDigest: string,
276
+ ): Promise<PurchaseReceipt | null> =>
277
+ request(
278
+ s.purchaseReceiptSchema,
279
+ `/receipts/${pressingId}/${txDigest}`,
280
+ {},
281
+ { nullOn404: true },
282
+ ),
223
283
  };
224
284
  }
225
285
 
@@ -254,14 +314,13 @@ export const READ_CACHE_CLASS = {
254
314
  getDiscover: "sale",
255
315
  getPressing: "sale",
256
316
  getDropPreview: "sale",
257
- getReleaseTrackCredits: "published",
258
317
  getRecordAlbum: "immutable",
259
318
  getReceipt: "immutable",
260
319
  getArtist: "artist",
261
- getArtistFeatured: "artist",
262
320
  getArtists: "artist",
263
321
  getWalletRecords: "private",
264
322
  getWalletParties: "private",
323
+ getPendingMemberships: "private",
265
324
  getWalletWorks: "private",
266
325
  getWork: "private",
267
326
  getBalance: "private",
package/src/schemas.ts CHANGED
@@ -63,9 +63,36 @@ export const trackViewSchema = z.object({
63
63
  disc: z.number().int().min(1),
64
64
  });
65
65
 
66
+ /** A recording's work-role credits and recording billing positions. */
67
+ export const recordingCreditsSchema = z.object({
68
+ credits: z.array(creditSchema),
69
+ primaryArtistIds: z.array(suiIdSchema),
70
+ featuredArtistIds: z.array(suiIdSchema),
71
+ });
72
+
73
+ /** Per-track credits for a release. Composition writing and recording
74
+ * performance/production credits remain separate. */
75
+ const completeTrackCreditsSchema = z.object({
76
+ compositionCredits: z.array(creditSchema),
77
+ recordingCredits: recordingCreditsSchema,
78
+ });
79
+
80
+ /** The HTTP wire accepts the complete shape and the deployed recording-only shape. */
81
+ export const trackCreditsWireSchema = z.union([
82
+ completeTrackCreditsSchema,
83
+ recordingCreditsSchema,
84
+ ]);
85
+
86
+ /** Clients always receive the complete shape, including during the rollout. */
87
+ export const trackCreditsSchema = trackCreditsWireSchema.transform((credits) =>
88
+ "recordingCredits" in credits
89
+ ? credits
90
+ : { compositionCredits: [], recordingCredits: credits },
91
+ );
92
+
66
93
  // ── Catalog ──────────────────────────────────────────────────────────────────
67
94
 
68
- export const releaseDetailSchema = z.object({
95
+ const releaseDetailBaseSchema = z.object({
69
96
  id: suiIdSchema,
70
97
  title: z.string(),
71
98
  subtitle: z.string().nullable(),
@@ -79,6 +106,16 @@ export const releaseDetailSchema = z.object({
79
106
  tracks: z.array(trackViewSchema),
80
107
  });
81
108
 
109
+ /** Transform-free wire form used by OpenAPI generation. */
110
+ export const releaseDetailWireSchema = releaseDetailBaseSchema.extend({
111
+ trackCredits: z.record(suiIdSchema, trackCreditsWireSchema).optional(),
112
+ });
113
+
114
+ export const releaseDetailSchema = releaseDetailBaseSchema.extend({
115
+ /** Present only when requested via `include`. */
116
+ trackCredits: z.record(suiIdSchema, trackCreditsSchema).optional(),
117
+ });
118
+
82
119
  export const priceSchema = z.object({
83
120
  /** `fixed` — pay exactly this. `floor` — pay at least this. */
84
121
  kind: z.enum(["fixed", "floor"]),
@@ -111,6 +148,11 @@ export const pressingDetailSchema = z.object({
111
148
  release: releaseDetailSchema,
112
149
  });
113
150
 
151
+ export const pressingDetailWireSchema = z.object({
152
+ pressing: pressingViewSchema,
153
+ release: releaseDetailWireSchema,
154
+ });
155
+
114
156
  export const discoverItemSchema = z.object({
115
157
  pressing: pressingViewSchema,
116
158
  releaseId: suiIdSchema,
@@ -125,6 +167,14 @@ export const discoverShelfSchema = z.array(discoverItemSchema);
125
167
  export const recordAlbumSchema = z.object({
126
168
  recordId: suiIdSchema,
127
169
  releaseId: suiIdSchema.nullable(),
170
+ /** Present only when requested via `include`. */
171
+ release: releaseDetailSchema.nullable().optional(),
172
+ });
173
+
174
+ export const recordAlbumWireSchema = z.object({
175
+ recordId: suiIdSchema,
176
+ releaseId: suiIdSchema.nullable(),
177
+ release: releaseDetailWireSchema.nullable().optional(),
128
178
  });
129
179
 
130
180
  export const dropPreviewSchema = z.object({
@@ -137,42 +187,6 @@ export const dropPreviewSchema = z.object({
137
187
  trackCount: z.number().int().min(0),
138
188
  });
139
189
 
140
- /** A recording's work-role credits and recording billing positions. */
141
- export const recordingCreditsSchema = z.object({
142
- credits: z.array(creditSchema),
143
- primaryArtistIds: z.array(suiIdSchema),
144
- featuredArtistIds: z.array(suiIdSchema),
145
- });
146
-
147
- /** Per-track credits for a release. Composition writing and recording
148
- * performance/production credits remain separate. */
149
- const completeTrackCreditsSchema = z.object({
150
- compositionCredits: z.array(creditSchema),
151
- recordingCredits: recordingCreditsSchema,
152
- });
153
-
154
- /** The HTTP wire accepts the complete shape and the deployed recording-only shape. */
155
- export const trackCreditsWireSchema = z.union([
156
- completeTrackCreditsSchema,
157
- recordingCreditsSchema,
158
- ]);
159
-
160
- /** Clients always receive the complete shape, including during the rollout. */
161
- export const trackCreditsSchema = trackCreditsWireSchema.transform((credits) =>
162
- "recordingCredits" in credits
163
- ? credits
164
- : { compositionCredits: [], recordingCredits: credits },
165
- );
166
-
167
- /** Transform-free wire schema used when generating OpenAPI. */
168
- export const releaseTrackCreditsWireSchema = z.object({
169
- tracks: z.record(suiIdSchema, trackCreditsWireSchema),
170
- });
171
- /** Per-track work credits for a release, keyed by recording id. */
172
- export const releaseTrackCreditsSchema = z.object({
173
- tracks: z.record(suiIdSchema, trackCreditsSchema),
174
- });
175
-
176
190
  // ── Artist ───────────────────────────────────────────────────────────────────
177
191
 
178
192
  export const partyMemberSchema = z.object({
@@ -229,6 +243,14 @@ export const partyLinkSchema = z.object({
229
243
 
230
244
  export const partyCtaSchema = z.object({ label: z.string(), url: z.string() });
231
245
 
246
+ export const featuredReleaseSchema = z.object({
247
+ pressingId: suiIdSchema,
248
+ releaseId: suiIdSchema,
249
+ title: z.string(),
250
+ artist: z.string(),
251
+ coverUrl: z.string().url().nullable(),
252
+ });
253
+
232
254
  export const artistProfileSchema = z.object({
233
255
  id: suiIdSchema,
234
256
  kind: z.enum(["individual", "group"]),
@@ -246,17 +268,11 @@ export const artistProfileSchema = z.object({
246
268
  /** Present only when requested via `include` — the owner-editor fields. */
247
269
  roles: z.array(z.string()).optional(),
248
270
  tags: z.array(z.string()).optional(),
271
+ /** Present only when requested; null means the artist has no pinned release. */
272
+ featured: featuredReleaseSchema.nullable().optional(),
249
273
  avatarUrl: z.string().url(),
250
274
  });
251
275
 
252
- export const featuredReleaseSchema = z.object({
253
- pressingId: suiIdSchema,
254
- releaseId: suiIdSchema,
255
- title: z.string(),
256
- artist: z.string(),
257
- coverUrl: z.string().url().nullable(),
258
- });
259
-
260
276
  export const partySummarySchema = z.object({
261
277
  id: suiIdSchema,
262
278
  name: z.string(),
@@ -286,6 +302,16 @@ export const ownedPartySchema = z.object({
286
302
 
287
303
  export const ownedPartiesSchema = z.array(ownedPartySchema);
288
304
 
305
+ /** A group invitation awaiting action by a party the wallet administers. */
306
+ export const pendingMembershipSchema = z.object({
307
+ memberPartyId: suiIdSchema,
308
+ memberCapId: suiIdSchema,
309
+ groupId: suiIdSchema,
310
+ groupName: z.string(),
311
+ });
312
+
313
+ export const pendingMembershipsSchema = z.array(pendingMembershipSchema);
314
+
289
315
  export const workKindSchema = z.enum(["composition", "recording", "release"]);
290
316
 
291
317
  export const ownedWorkSchema = z.object({
@@ -354,6 +380,13 @@ export const purchaseReceiptSchema = z.object({
354
380
  tracks: z.array(trackRoyaltySchema),
355
381
  });
356
382
 
383
+ export const purchaseReceiptWireSchema = z.object({
384
+ sale: recordSaleSchema,
385
+ detail: pressingDetailWireSchema,
386
+ price: u64Schema,
387
+ tracks: z.array(trackRoyaltySchema),
388
+ });
389
+
357
390
  // ── Errors ───────────────────────────────────────────────────────────────────
358
391
 
359
392
  /** The envelope every non-2xx carries, matching miso-api's `apiError`. */
package/src/types.ts CHANGED
@@ -15,7 +15,6 @@ export type TrackView = z.infer<typeof s.trackViewSchema>;
15
15
 
16
16
  export type ReleaseDetail = z.infer<typeof s.releaseDetailSchema>;
17
17
  export type TrackCredits = z.infer<typeof s.trackCreditsSchema>;
18
- export type ReleaseTrackCredits = z.infer<typeof s.releaseTrackCreditsSchema>;
19
18
  export type Price = z.infer<typeof s.priceSchema>;
20
19
  export type Currency = z.infer<typeof s.currencySchema>;
21
20
  export type PressingView = z.infer<typeof s.pressingViewSchema>;
@@ -35,6 +34,7 @@ export type PartySummary = z.infer<typeof s.partySummarySchema>;
35
34
 
36
35
  export type OwnedRecord = z.infer<typeof s.ownedRecordSchema>;
37
36
  export type OwnedParty = z.infer<typeof s.ownedPartySchema>;
37
+ export type PendingMembership = z.infer<typeof s.pendingMembershipSchema>;
38
38
  export type WorkKind = z.infer<typeof s.workKindSchema>;
39
39
  export type OwnedWork = z.infer<typeof s.ownedWorkSchema>;
40
40
  export type WorkDetail = z.infer<typeof s.workDetailSchema>;