@misofm/api-client 0.5.1 → 0.5.2
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/LICENSE +201 -0
- package/README.md +171 -5
- package/dist/cache.d.ts +71 -0
- package/dist/cache.js +119 -0
- package/dist/client.d.ts +150 -0
- package/dist/client.js +265 -0
- package/dist/index.d.ts +6 -0
- package/{src/index.ts → dist/index.js} +3 -9
- package/dist/schemas.d.ts +1314 -0
- package/dist/schemas.js +335 -0
- package/{src/types.ts → dist/types.d.ts} +1 -13
- package/dist/types.js +6 -0
- package/package.json +26 -12
- package/src/cache.test.ts +0 -90
- package/src/cache.ts +0 -91
- package/src/client.test.ts +0 -284
- package/src/client.ts +0 -325
- package/src/schemas.ts +0 -384
package/src/client.test.ts
DELETED
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Miso Labs, Inc.
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
|
|
4
|
-
import { describe, expect, test } from "bun:test";
|
|
5
|
-
import {
|
|
6
|
-
cacheBuster,
|
|
7
|
-
createMisoApiClient,
|
|
8
|
-
MisoApiContractError,
|
|
9
|
-
MisoApiError,
|
|
10
|
-
READ_CACHE_CLASS,
|
|
11
|
-
} from "./client.ts";
|
|
12
|
-
|
|
13
|
-
/** A fetch that records the URL it was called with and replays a canned response. */
|
|
14
|
-
function stubFetch(response: { status?: number; body?: unknown }) {
|
|
15
|
-
const calls: string[] = [];
|
|
16
|
-
const fetch = (async (input: string | URL) => {
|
|
17
|
-
calls.push(String(input));
|
|
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
|
-
);
|
|
25
|
-
}) as unknown as typeof globalThis.fetch;
|
|
26
|
-
return { fetch, calls };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const BASE = "https://api.testnet.miso.fm";
|
|
30
|
-
|
|
31
|
-
const balance = {
|
|
32
|
-
address: "0xabc",
|
|
33
|
-
coinType: "0x7777::fakeusd::FakeUsd",
|
|
34
|
-
balance: "100000000",
|
|
35
|
-
decimals: 6,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
describe("URL construction", () => {
|
|
39
|
-
test("mounts reads under the gateway's read prefix", async () => {
|
|
40
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
41
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
|
|
42
|
-
expect(calls[0]).toBe(`${BASE}/read/v1/wallets/0xabc/balance`);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("tolerates a trailing slash on the base URL", async () => {
|
|
46
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
47
|
-
await createMisoApiClient({ baseUrl: `${BASE}/`, fetch }).getBalance(
|
|
48
|
-
"0xabc",
|
|
49
|
-
);
|
|
50
|
-
expect(calls[0]).toBe(`${BASE}/read/v1/wallets/0xabc/balance`);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("omits empty query params rather than sending them blank", async () => {
|
|
54
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
55
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
|
|
56
|
-
"0xabc",
|
|
57
|
-
undefined,
|
|
58
|
-
);
|
|
59
|
-
expect(calls[0]).not.toContain("coinType");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("sends an explicit coin type when given one", async () => {
|
|
63
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
64
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance(
|
|
65
|
-
"0xabc",
|
|
66
|
-
"0x2::sui::SUI",
|
|
67
|
-
);
|
|
68
|
-
expect(calls[0]).toContain("coinType=0x2%3A%3Asui%3A%3ASUI");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("joins the artist include list into one param", async () => {
|
|
72
|
-
const { fetch, calls } = stubFetch({ status: 404 });
|
|
73
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xp", {
|
|
74
|
-
include: ["roles", "tags"],
|
|
75
|
-
});
|
|
76
|
-
expect(calls[0]).toContain("include=roles%2Ctags");
|
|
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.getRelease("0xl", { include: ["trackCredits"] });
|
|
83
|
-
await client.getRecordAlbum("0xr", {
|
|
84
|
-
include: ["release", "trackCredits"],
|
|
85
|
-
});
|
|
86
|
-
expect(calls[0]).toContain("include=trackCredits");
|
|
87
|
-
expect(calls[1]).toContain("include=release%2CtrackCredits");
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("passes the currency to the modular Listing route", async () => {
|
|
91
|
-
const { fetch, calls } = stubFetch({ status: 404 });
|
|
92
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getListing(
|
|
93
|
-
"0xp",
|
|
94
|
-
"0x2::sui::SUI",
|
|
95
|
-
);
|
|
96
|
-
expect(calls[0]).toBe(
|
|
97
|
-
`${BASE}/read/v1/pressings/0xp/listing?currencyType=0x2%3A%3Asui%3A%3ASUI`,
|
|
98
|
-
);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("a custom prefix is honored (self-hosted / direct-to-service)", async () => {
|
|
102
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
103
|
-
await createMisoApiClient({
|
|
104
|
-
baseUrl: BASE,
|
|
105
|
-
fetch,
|
|
106
|
-
prefix: "/v1",
|
|
107
|
-
}).getBalance("0xabc");
|
|
108
|
-
expect(calls[0]).toBe(`${BASE}/v1/wallets/0xabc/balance`);
|
|
109
|
-
});
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
describe("not-found handling", () => {
|
|
113
|
-
test("an unknown pressing is null, not an error", async () => {
|
|
114
|
-
const { fetch } = stubFetch({
|
|
115
|
-
status: 404,
|
|
116
|
-
body: { error: { code: "not_found", message: "gone" } },
|
|
117
|
-
});
|
|
118
|
-
expect(
|
|
119
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getPressing("0xdead"),
|
|
120
|
-
).toBeNull();
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("an unknown artist is null", async () => {
|
|
124
|
-
const { fetch } = stubFetch({
|
|
125
|
-
status: 404,
|
|
126
|
-
body: { error: { code: "not_found", message: "gone" } },
|
|
127
|
-
});
|
|
128
|
-
expect(
|
|
129
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getArtist("0xdead"),
|
|
130
|
-
).toBeNull();
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("an empty id list short-circuits without a request", async () => {
|
|
134
|
-
const { fetch, calls } = stubFetch({ body: [] });
|
|
135
|
-
expect(
|
|
136
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getArtists([]),
|
|
137
|
-
).toEqual([]);
|
|
138
|
-
expect(calls).toHaveLength(0);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("error handling", () => {
|
|
143
|
-
test("surfaces the server's error code and message", async () => {
|
|
144
|
-
const { fetch } = stubFetch({
|
|
145
|
-
status: 429,
|
|
146
|
-
body: { error: { code: "rate-limited", message: "Too many requests." } },
|
|
147
|
-
});
|
|
148
|
-
const api = createMisoApiClient({ baseUrl: BASE, fetch });
|
|
149
|
-
await expect(api.getPressing("0xp")).rejects.toThrow(MisoApiError);
|
|
150
|
-
try {
|
|
151
|
-
await api.getPressing("0xp");
|
|
152
|
-
} catch (e) {
|
|
153
|
-
expect(e).toBeInstanceOf(MisoApiError);
|
|
154
|
-
expect((e as MisoApiError).status).toBe(429);
|
|
155
|
-
expect((e as MisoApiError).code).toBe("rate-limited");
|
|
156
|
-
expect((e as MisoApiError).message).toBe("Too many requests.");
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("an error body in an unexpected shape still throws a usable error", async () => {
|
|
161
|
-
const { fetch } = stubFetch({ status: 500, body: { oops: true } });
|
|
162
|
-
try {
|
|
163
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getPressing("0xp");
|
|
164
|
-
throw new Error("should have thrown");
|
|
165
|
-
} catch (e) {
|
|
166
|
-
expect(e).toBeInstanceOf(MisoApiError);
|
|
167
|
-
expect((e as MisoApiError).code).toBe("unknown");
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("a 404 on an endpoint that must return a body is an error, not null", async () => {
|
|
172
|
-
const { fetch } = stubFetch({
|
|
173
|
-
status: 404,
|
|
174
|
-
body: { error: { code: "not_found", message: "gone" } },
|
|
175
|
-
});
|
|
176
|
-
await expect(
|
|
177
|
-
createMisoApiClient({ baseUrl: BASE, fetch }).getWalletRecords("0xabc"),
|
|
178
|
-
).rejects.toThrow(MisoApiError);
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe("contract validation", () => {
|
|
183
|
-
test("a response missing a required field fails loudly at the boundary", async () => {
|
|
184
|
-
const { fetch } = stubFetch({
|
|
185
|
-
body: { address: "0xabc", coinType: "0x2::sui::SUI" },
|
|
186
|
-
}); // no balance
|
|
187
|
-
await expect(
|
|
188
|
-
createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc"),
|
|
189
|
-
).rejects.toThrow(MisoApiContractError);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
test("a u64 sent as a NUMBER is rejected — the precision bug this contract exists to prevent", async () => {
|
|
193
|
-
const { fetch } = stubFetch({ body: { ...balance, balance: 100000000 } });
|
|
194
|
-
const err = await createMisoApiClient({ baseUrl: BASE, fetch })
|
|
195
|
-
.getBalance("0xabc")
|
|
196
|
-
.catch((e: unknown) => e);
|
|
197
|
-
expect(err).toBeInstanceOf(MisoApiContractError);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("the contract error names the field and points at version skew", async () => {
|
|
201
|
-
const { fetch } = stubFetch({
|
|
202
|
-
body: { ...balance, balance: "not-a-number" },
|
|
203
|
-
});
|
|
204
|
-
try {
|
|
205
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
|
|
206
|
-
throw new Error("should have thrown");
|
|
207
|
-
} catch (e) {
|
|
208
|
-
expect((e as Error).message).toContain("balance");
|
|
209
|
-
expect((e as Error).message).toContain("different versions");
|
|
210
|
-
}
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test("a valid response parses through to typed data", async () => {
|
|
214
|
-
const { fetch } = stubFetch({ body: balance });
|
|
215
|
-
const result = await createMisoApiClient({
|
|
216
|
-
baseUrl: BASE,
|
|
217
|
-
fetch,
|
|
218
|
-
}).getBalance("0xabc");
|
|
219
|
-
expect(result).toEqual(balance);
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
describe("READ_CACHE_CLASS", () => {
|
|
224
|
-
test("every wallet-scoped read is private", () => {
|
|
225
|
-
for (const [method, cls] of Object.entries(READ_CACHE_CLASS)) {
|
|
226
|
-
if (
|
|
227
|
-
method.startsWith("getWallet") ||
|
|
228
|
-
method.startsWith("owns") ||
|
|
229
|
-
method === "getBalance" ||
|
|
230
|
-
method === "getWork"
|
|
231
|
-
) {
|
|
232
|
-
expect(cls).toBe("private");
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test("reads whose answer can never change are immutable", () => {
|
|
238
|
-
expect(READ_CACHE_CLASS.getRecordAlbum).toBe("immutable");
|
|
239
|
-
expect(READ_CACHE_CLASS.getReceipt).toBe("immutable");
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test("live sale reads are the sale class", () => {
|
|
243
|
-
expect(READ_CACHE_CLASS.getPressing).toBe("sale");
|
|
244
|
-
expect(READ_CACHE_CLASS.getListing).toBe("sale");
|
|
245
|
-
});
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
describe("cache buster", () => {
|
|
249
|
-
test("no `v` is sent until something has been written", async () => {
|
|
250
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
251
|
-
await createMisoApiClient({ baseUrl: BASE, fetch }).getBalance("0xabc");
|
|
252
|
-
expect(calls[0]).not.toContain("v=");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
test("a supplied version rides on every read as ?v=", async () => {
|
|
256
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
257
|
-
const api = createMisoApiClient({
|
|
258
|
-
baseUrl: BASE,
|
|
259
|
-
fetch,
|
|
260
|
-
version: () => "1699999999",
|
|
261
|
-
});
|
|
262
|
-
await api.getBalance("0xabc");
|
|
263
|
-
expect(calls[0]).toContain("v=1699999999");
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
test("the version is read per request, so one client can be bumped in place", async () => {
|
|
267
|
-
const { fetch, calls } = stubFetch({ body: balance });
|
|
268
|
-
let v: string | undefined;
|
|
269
|
-
const api = createMisoApiClient({ baseUrl: BASE, fetch, version: () => v });
|
|
270
|
-
await api.getBalance("0xabc");
|
|
271
|
-
v = "42";
|
|
272
|
-
await api.getBalance("0xabc");
|
|
273
|
-
expect(calls[0]).not.toContain("v=");
|
|
274
|
-
expect(calls[1]).toContain("v=42");
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
test("cacheBuster is per-SECOND, so a burst after one write shares an entry", () => {
|
|
278
|
-
// Millisecond granularity would mint a fresh cache entry per read.
|
|
279
|
-
expect(cacheBuster(1_700_000_000_123)).toBe(cacheBuster(1_700_000_000_900));
|
|
280
|
-
expect(cacheBuster(1_700_000_000_000)).not.toBe(
|
|
281
|
-
cacheBuster(1_700_000_001_000),
|
|
282
|
-
);
|
|
283
|
-
});
|
|
284
|
-
});
|
package/src/client.ts
DELETED
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
// Copyright (c) Miso Labs, Inc.
|
|
2
|
-
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
//
|
|
4
|
-
// The typed client for the Miso read API. One of these per app.
|
|
5
|
-
//
|
|
6
|
-
// const api = createMisoApiClient({ baseUrl: "https://api.testnet.miso.fm" })
|
|
7
|
-
// const pressing = await api.getPressing("0x…")
|
|
8
|
-
//
|
|
9
|
-
// Every method returns a PARSED, validated body. Validation on the client is not
|
|
10
|
-
// paranoia about our own server — it is what turns a silent contract break into a
|
|
11
|
-
// loud one at the boundary that noticed, instead of a `Cannot read property of
|
|
12
|
-
// undefined` three components deep.
|
|
13
|
-
//
|
|
14
|
-
// Reads that can legitimately find nothing (a pressing that was superseded, a
|
|
15
|
-
// party with no pin) return `null` on 404 rather than throwing. Everything else
|
|
16
|
-
// throws `MisoApiError` carrying the server's `{ error: { code, message } }`.
|
|
17
|
-
|
|
18
|
-
import type { z } from "zod";
|
|
19
|
-
import * as s from "./schemas.ts";
|
|
20
|
-
import { queryPolicy, type CacheClass } from "./cache.ts";
|
|
21
|
-
import type {
|
|
22
|
-
ArtistProfile,
|
|
23
|
-
Balance,
|
|
24
|
-
ListingView,
|
|
25
|
-
OwnedParty,
|
|
26
|
-
PendingMembership,
|
|
27
|
-
OwnedRecord,
|
|
28
|
-
OwnedWork,
|
|
29
|
-
Ownership,
|
|
30
|
-
PartySummary,
|
|
31
|
-
PressingView,
|
|
32
|
-
PurchaseReceipt,
|
|
33
|
-
RecordAlbum,
|
|
34
|
-
ReleaseDetail,
|
|
35
|
-
WorkDetail,
|
|
36
|
-
} from "./types.ts";
|
|
37
|
-
|
|
38
|
-
export class MisoApiError extends Error {
|
|
39
|
-
readonly status: number;
|
|
40
|
-
readonly code: string;
|
|
41
|
-
|
|
42
|
-
constructor(status: number, code: string, message: string) {
|
|
43
|
-
super(message);
|
|
44
|
-
this.name = "MisoApiError";
|
|
45
|
-
this.status = status;
|
|
46
|
-
this.code = code;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The server said something this client cannot describe. Distinct from
|
|
52
|
-
* `MisoApiError` because the fix is different: an API error is an expected
|
|
53
|
-
* outcome to render, a contract error is a version skew to deploy past.
|
|
54
|
-
*/
|
|
55
|
-
export class MisoApiContractError extends Error {
|
|
56
|
-
readonly issues: z.core.$ZodIssue[];
|
|
57
|
-
|
|
58
|
-
constructor(path: string, issues: z.core.$ZodIssue[]) {
|
|
59
|
-
const first = issues[0];
|
|
60
|
-
super(
|
|
61
|
-
`Response from ${path} did not match the expected contract` +
|
|
62
|
-
(first ? `: ${first.path.join(".")} — ${first.message}` : "") +
|
|
63
|
-
". The API and @misofm/api-client are likely different versions.",
|
|
64
|
-
);
|
|
65
|
-
this.name = "MisoApiContractError";
|
|
66
|
-
this.issues = issues;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface MisoApiClientOptions {
|
|
71
|
-
/** Public API origin, e.g. `https://api.testnet.miso.fm`. */
|
|
72
|
-
baseUrl: string;
|
|
73
|
-
/**
|
|
74
|
-
* Cache buster appended to every read as `?v=`. There is no purge layer — the
|
|
75
|
-
* edge cache is an optimization, not a source of truth — so a surface that has
|
|
76
|
-
* just WRITTEN something and needs to see its own change before the TTL
|
|
77
|
-
* expires supplies a fresh value here (see {@link cacheBuster}).
|
|
78
|
-
*
|
|
79
|
-
* Returns a value rather than taking one so a single long-lived client can be
|
|
80
|
-
* bumped in place. Returning `undefined` (the default) sends no `v` at all,
|
|
81
|
-
* which is what every read should do until something is written.
|
|
82
|
-
*/
|
|
83
|
-
version?: () => string | undefined;
|
|
84
|
-
/** Injectable for tests, Workers, and anything with its own fetch. */
|
|
85
|
-
fetch?: typeof globalThis.fetch;
|
|
86
|
-
/**
|
|
87
|
-
* Path the read endpoints are mounted under on `baseUrl`. The gateway routes
|
|
88
|
-
* `/read/*` to the read service, whose own routes are rooted at `/v1`.
|
|
89
|
-
*/
|
|
90
|
-
prefix?: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
type QueryValue = string | number | boolean | undefined | null;
|
|
94
|
-
|
|
95
|
-
export function createMisoApiClient(options: MisoApiClientOptions) {
|
|
96
|
-
const base = options.baseUrl.replace(/\/$/, "");
|
|
97
|
-
const prefix = options.prefix ?? "/read/v1";
|
|
98
|
-
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
99
|
-
|
|
100
|
-
function url(path: string, query: Record<string, QueryValue> = {}): string {
|
|
101
|
-
const u = new URL(`${base}${prefix}${path}`);
|
|
102
|
-
for (const [k, v] of Object.entries(query)) {
|
|
103
|
-
if (v !== undefined && v !== null && v !== "")
|
|
104
|
-
u.searchParams.set(k, String(v));
|
|
105
|
-
}
|
|
106
|
-
const v = options.version?.();
|
|
107
|
-
if (v) u.searchParams.set("v", v);
|
|
108
|
-
return u.toString();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function request<T>(
|
|
112
|
-
schema: z.ZodType<T>,
|
|
113
|
-
path: string,
|
|
114
|
-
query: Record<string, QueryValue> = {},
|
|
115
|
-
opts: { nullOn404?: boolean } = {},
|
|
116
|
-
): Promise<T | null> {
|
|
117
|
-
const target = url(path, query);
|
|
118
|
-
const res = await doFetch(target, {
|
|
119
|
-
headers: { Accept: "application/json" },
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
if (res.status === 404 && opts.nullOn404) return null;
|
|
123
|
-
|
|
124
|
-
if (!res.ok) {
|
|
125
|
-
const body = await res.json().catch(() => null);
|
|
126
|
-
const parsed = s.apiErrorSchema.safeParse(body);
|
|
127
|
-
throw parsed.success
|
|
128
|
-
? new MisoApiError(
|
|
129
|
-
res.status,
|
|
130
|
-
parsed.data.error.code,
|
|
131
|
-
parsed.data.error.message,
|
|
132
|
-
)
|
|
133
|
-
: new MisoApiError(
|
|
134
|
-
res.status,
|
|
135
|
-
"unknown",
|
|
136
|
-
`Request to ${path} failed (${res.status})`,
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const json = await res.json().catch(() => {
|
|
141
|
-
throw new MisoApiError(
|
|
142
|
-
res.status,
|
|
143
|
-
"bad_response",
|
|
144
|
-
`Response from ${path} was not JSON`,
|
|
145
|
-
);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
const parsed = schema.safeParse(json);
|
|
149
|
-
if (!parsed.success)
|
|
150
|
-
throw new MisoApiContractError(path, parsed.error.issues);
|
|
151
|
-
return parsed.data;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Same as `request`, for endpoints that always return a body. */
|
|
155
|
-
async function required<T>(
|
|
156
|
-
schema: z.ZodType<T>,
|
|
157
|
-
path: string,
|
|
158
|
-
query?: Record<string, QueryValue>,
|
|
159
|
-
): Promise<T> {
|
|
160
|
-
return (await request(schema, path, query)) as T;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return {
|
|
164
|
-
// ── Catalog ─────────────────────────────────────────────────────────────
|
|
165
|
-
/** One permanent pressing resource. */
|
|
166
|
-
getPressing: (pressingId: string): Promise<PressingView | null> =>
|
|
167
|
-
request(
|
|
168
|
-
s.pressingViewSchema,
|
|
169
|
-
`/pressings/${pressingId}`,
|
|
170
|
-
{},
|
|
171
|
-
{ nullOn404: true },
|
|
172
|
-
),
|
|
173
|
-
|
|
174
|
-
/** One currency-specific Listing derived from a permanent Pressing. */
|
|
175
|
-
getListing: (
|
|
176
|
-
pressingId: string,
|
|
177
|
-
currencyType: string,
|
|
178
|
-
): Promise<ListingView | null> =>
|
|
179
|
-
request(
|
|
180
|
-
s.listingViewSchema,
|
|
181
|
-
`/pressings/${pressingId}/listing`,
|
|
182
|
-
{ currencyType },
|
|
183
|
-
{ nullOn404: true },
|
|
184
|
-
),
|
|
185
|
-
|
|
186
|
-
/** A release with its cover, credits, and resolved tracklist. */
|
|
187
|
-
getRelease: (
|
|
188
|
-
releaseId: string,
|
|
189
|
-
opts: { include?: readonly "trackCredits"[] } = {},
|
|
190
|
-
): Promise<ReleaseDetail | null> =>
|
|
191
|
-
request(
|
|
192
|
-
s.releaseDetailSchema,
|
|
193
|
-
`/releases/${releaseId}`,
|
|
194
|
-
{ include: opts.include?.join(",") },
|
|
195
|
-
{ nullOn404: true },
|
|
196
|
-
),
|
|
197
|
-
|
|
198
|
-
/** A record's parent release. Immutable for the life of the record. */
|
|
199
|
-
getRecordAlbum: (
|
|
200
|
-
recordId: string,
|
|
201
|
-
opts: { include?: readonly ("release" | "trackCredits")[] } = {},
|
|
202
|
-
): Promise<RecordAlbum | null> =>
|
|
203
|
-
request(
|
|
204
|
-
s.recordAlbumSchema,
|
|
205
|
-
`/records/${recordId}/album`,
|
|
206
|
-
{ include: opts.include?.join(",") },
|
|
207
|
-
{ nullOn404: true },
|
|
208
|
-
),
|
|
209
|
-
|
|
210
|
-
// ── Artist ──────────────────────────────────────────────────────────────
|
|
211
|
-
/** An artist plus optional relationship expansions. */
|
|
212
|
-
getArtist: (
|
|
213
|
-
partyId: string,
|
|
214
|
-
opts: { include?: readonly ("roles" | "tags")[] } = {},
|
|
215
|
-
): Promise<ArtistProfile | null> =>
|
|
216
|
-
request(
|
|
217
|
-
s.artistProfileSchema,
|
|
218
|
-
`/artists/${partyId}`,
|
|
219
|
-
{ include: opts.include?.join(",") },
|
|
220
|
-
{ nullOn404: true },
|
|
221
|
-
),
|
|
222
|
-
|
|
223
|
-
/** Name + kind for many parties at once. */
|
|
224
|
-
getArtists: (ids: readonly string[]): Promise<PartySummary[]> =>
|
|
225
|
-
ids.length === 0
|
|
226
|
-
? Promise.resolve([])
|
|
227
|
-
: required(s.partySummariesSchema, "/artists", { ids: ids.join(",") }),
|
|
228
|
-
|
|
229
|
-
// ── Wallet-scoped ───────────────────────────────────────────────────────
|
|
230
|
-
/** The records this wallet holds. */
|
|
231
|
-
getWalletRecords: (address: string): Promise<OwnedRecord[]> =>
|
|
232
|
-
required(s.ownedRecordsSchema, `/wallets/${address}/records`),
|
|
233
|
-
|
|
234
|
-
/** The parties this wallet administers. */
|
|
235
|
-
getWalletParties: (address: string): Promise<OwnedParty[]> =>
|
|
236
|
-
required(s.ownedPartiesSchema, `/wallets/${address}/parties`),
|
|
237
|
-
|
|
238
|
-
/** Group invitations awaiting acceptance by this wallet's controlled parties. */
|
|
239
|
-
getPendingMemberships: (address: string): Promise<PendingMembership[]> =>
|
|
240
|
-
required(s.pendingMembershipsSchema, `/wallets/${address}/pending-memberships`),
|
|
241
|
-
|
|
242
|
-
/** The works this wallet administers, keyed by admin cap. */
|
|
243
|
-
getWalletWorks: (address: string): Promise<OwnedWork[]> =>
|
|
244
|
-
required(s.ownedWorksSchema, `/wallets/${address}/works`),
|
|
245
|
-
|
|
246
|
-
/** One administered work, by its admin cap id. */
|
|
247
|
-
getWork: (capId: string): Promise<WorkDetail | null> =>
|
|
248
|
-
request(s.workDetailSchema, `/works/${capId}`, {}, { nullOn404: true }),
|
|
249
|
-
|
|
250
|
-
/** Spendable balance. Defaults to the app's dollar when `coinType` is omitted. */
|
|
251
|
-
getBalance: (address: string, coinType?: string): Promise<Balance> =>
|
|
252
|
-
required(s.balanceSchema, `/wallets/${address}/balance`, { coinType }),
|
|
253
|
-
|
|
254
|
-
/** Whether the wallet holds this party's admin cap. Carries the cap id. */
|
|
255
|
-
ownsParty: (address: string, partyId: string): Promise<Ownership> =>
|
|
256
|
-
required(s.ownershipSchema, `/wallets/${address}/owns`, {
|
|
257
|
-
party: partyId,
|
|
258
|
-
}),
|
|
259
|
-
|
|
260
|
-
/** Whether the wallet owns this record. */
|
|
261
|
-
ownsRecord: (address: string, recordId: string): Promise<Ownership> =>
|
|
262
|
-
required(s.ownershipSchema, `/wallets/${address}/owns`, {
|
|
263
|
-
record: recordId,
|
|
264
|
-
}),
|
|
265
|
-
|
|
266
|
-
// ── Receipts ────────────────────────────────────────────────────────────
|
|
267
|
-
/** What one record sale was, re-derived from its transaction. */
|
|
268
|
-
getReceipt: (
|
|
269
|
-
pressingId: string,
|
|
270
|
-
txDigest: string,
|
|
271
|
-
): Promise<PurchaseReceipt | null> =>
|
|
272
|
-
request(
|
|
273
|
-
s.purchaseReceiptSchema,
|
|
274
|
-
`/receipts/${pressingId}/${txDigest}`,
|
|
275
|
-
{},
|
|
276
|
-
{ nullOn404: true },
|
|
277
|
-
),
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
export type MisoApiClient = ReturnType<typeof createMisoApiClient>;
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* A cache-buster value for {@link MisoApiClientOptions.version}.
|
|
285
|
-
*
|
|
286
|
-
* Bump this after a write lands on-chain and the writer's next read mints a
|
|
287
|
-
* fresh cache entry instead of being served the pre-write one. It is deliberately
|
|
288
|
-
* coarse — seconds, not milliseconds — so a burst of reads right after one write
|
|
289
|
-
* shares a single entry rather than minting one each.
|
|
290
|
-
*
|
|
291
|
-
* Request-side revalidation is NOT an alternative: measured against the deployed
|
|
292
|
-
* edge on 2026-08-09, both `?fresh=1` and a raw `Cache-Control: no-cache`
|
|
293
|
-
* request header returned cache hits (~50ms) against a ~350ms origin fill.
|
|
294
|
-
* Workers Cache does not honour them, so the buster has to be in the key.
|
|
295
|
-
*/
|
|
296
|
-
export function cacheBuster(nowMs: number = Date.now()): string {
|
|
297
|
-
return String(Math.floor(nowMs / 1000));
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* The cache class each read belongs to, so a caller can align its own query
|
|
302
|
-
* options with the edge's TTL: `useQuery({ ...queryPolicy(READ_CACHE_CLASS.getPressing), … })`.
|
|
303
|
-
*
|
|
304
|
-
* `getRelease` is absent on purpose — a release's class depends on whether it is
|
|
305
|
-
* published, which is a property of the response, not the route. Callers derive
|
|
306
|
-
* it with `workCacheClass(release.state)`.
|
|
307
|
-
*/
|
|
308
|
-
export const READ_CACHE_CLASS = {
|
|
309
|
-
getPressing: "sale",
|
|
310
|
-
getListing: "sale",
|
|
311
|
-
getRecordAlbum: "immutable",
|
|
312
|
-
getReceipt: "immutable",
|
|
313
|
-
getArtist: "artist",
|
|
314
|
-
getArtists: "artist",
|
|
315
|
-
getWalletRecords: "private",
|
|
316
|
-
getWalletParties: "private",
|
|
317
|
-
getPendingMemberships: "private",
|
|
318
|
-
getWalletWorks: "private",
|
|
319
|
-
getWork: "private",
|
|
320
|
-
getBalance: "private",
|
|
321
|
-
ownsParty: "private",
|
|
322
|
-
ownsRecord: "private",
|
|
323
|
-
} as const satisfies Record<string, CacheClass>;
|
|
324
|
-
|
|
325
|
-
export { queryPolicy };
|