@graffiti-garden/implementation-decentralized 0.1.1 → 0.1.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/dist/1-services/3-storage-buckets.d.ts +5 -0
- package/dist/1-services/3-storage-buckets.d.ts.map +1 -1
- package/dist/browser/index.js +7 -7
- package/dist/browser/index.js.map +3 -3
- package/dist/cjs/1-services/3-storage-buckets-tests.js +179 -0
- package/dist/cjs/1-services/3-storage-buckets-tests.js.map +3 -3
- package/dist/cjs/1-services/3-storage-buckets.js +91 -0
- package/dist/cjs/1-services/3-storage-buckets.js.map +3 -3
- package/dist/cjs/1-services/4-inboxes-tests.js +20 -0
- package/dist/cjs/1-services/4-inboxes-tests.js.map +3 -3
- package/dist/esm/1-services/3-storage-buckets-tests.js +180 -1
- package/dist/esm/1-services/3-storage-buckets-tests.js.map +2 -2
- package/dist/esm/1-services/3-storage-buckets.js +92 -1
- package/dist/esm/1-services/3-storage-buckets.js.map +2 -2
- package/dist/esm/1-services/4-inboxes-tests.js +20 -0
- package/dist/esm/1-services/4-inboxes-tests.js.map +2 -2
- package/package.json +1 -1
- package/src/1-services/3-storage-buckets-tests.ts +199 -1
- package/src/1-services/3-storage-buckets.ts +129 -1
- package/src/1-services/4-inboxes-tests.ts +25 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { StorageBuckets } from "./3-storage-buckets";
|
|
3
3
|
import { GraffitiErrorUnauthorized } from "./utilities";
|
|
4
|
-
import { GraffitiErrorNotFound } from "@graffiti-garden/api";
|
|
4
|
+
import { GraffitiErrorNotFound, GraffitiErrorTooLarge } from "@graffiti-garden/api";
|
|
5
|
+
import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
|
|
5
6
|
|
|
6
7
|
export function storageBucketTests(
|
|
7
8
|
storageBucketEndpoint: string,
|
|
@@ -62,6 +63,203 @@ export function storageBucketTests(
|
|
|
62
63
|
).rejects.toThrow();
|
|
63
64
|
});
|
|
64
65
|
|
|
66
|
+
test("overwrites across the small and large storage cutoff", async () => {
|
|
67
|
+
const key = crypto.randomUUID();
|
|
68
|
+
const small = new Uint8Array([1, 2, 3]);
|
|
69
|
+
const large = new Uint8Array(32 * 1024 + 1).fill(42);
|
|
70
|
+
const otherLarge = new Uint8Array(32 * 1024 + 1).fill(43);
|
|
71
|
+
|
|
72
|
+
await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);
|
|
73
|
+
expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);
|
|
74
|
+
|
|
75
|
+
await storageBuckets.put(storageBucketEndpoint, key, otherLarge, storageBucketToken);
|
|
76
|
+
expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(otherLarge);
|
|
77
|
+
|
|
78
|
+
await storageBuckets.put(storageBucketEndpoint, key, small, storageBucketToken);
|
|
79
|
+
expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(small);
|
|
80
|
+
|
|
81
|
+
await storageBuckets.put(storageBucketEndpoint, key, large, storageBucketToken);
|
|
82
|
+
expect(await storageBuckets.get(storageBucketEndpoint, key)).toEqual(large);
|
|
83
|
+
|
|
84
|
+
await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
|
|
85
|
+
await expect(storageBuckets.get(storageBucketEndpoint, key)).rejects.toThrow(
|
|
86
|
+
GraffitiErrorNotFound,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("batch reads arbitrary keys from one bucket", async () => {
|
|
91
|
+
const first = crypto.randomUUID();
|
|
92
|
+
const second = crypto.randomUUID();
|
|
93
|
+
const missing = crypto.randomUUID();
|
|
94
|
+
const small = new Uint8Array([1, 2, 3]);
|
|
95
|
+
const large = new Uint8Array(32 * 1024 + 1).fill(7);
|
|
96
|
+
await storageBuckets.put(storageBucketEndpoint, first, small, storageBucketToken);
|
|
97
|
+
await storageBuckets.put(storageBucketEndpoint, second, large, storageBucketToken);
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(`${storageBucketEndpoint}/values`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/cbor" },
|
|
102
|
+
body: dagCborEncode({
|
|
103
|
+
keys: [first, missing, second, first],
|
|
104
|
+
maxValueBytes: small.length,
|
|
105
|
+
}).slice(),
|
|
106
|
+
});
|
|
107
|
+
expect(response.status).toBe(200);
|
|
108
|
+
const decoded = dagCborDecode(await response.arrayBuffer()) as {
|
|
109
|
+
results: Array<{ status: number; value?: Uint8Array; etag?: string }>;
|
|
110
|
+
};
|
|
111
|
+
expect(decoded.results.map(({ status }) => status)).toEqual([200, 404, 413, 200]);
|
|
112
|
+
expect(decoded.results[0].value).toEqual(small);
|
|
113
|
+
expect(decoded.results[0].etag).toBeTypeOf("string");
|
|
114
|
+
expect(decoded.results[3].value).toEqual(small);
|
|
115
|
+
const largeResponse = await fetch(`${storageBucketEndpoint}/values`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/cbor" },
|
|
118
|
+
body: dagCborEncode({
|
|
119
|
+
keys: [second],
|
|
120
|
+
maxValueBytes: large.length,
|
|
121
|
+
}).slice(),
|
|
122
|
+
});
|
|
123
|
+
expect(largeResponse.status).toBe(200);
|
|
124
|
+
const largeDecoded = dagCborDecode(await largeResponse.arrayBuffer()) as {
|
|
125
|
+
results: Array<{ status: number; value?: Uint8Array }>;
|
|
126
|
+
};
|
|
127
|
+
expect(largeDecoded.results[0].value).toEqual(large);
|
|
128
|
+
|
|
129
|
+
const oversizedResponse = await fetch(`${storageBucketEndpoint}/values`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: { "Content-Type": "application/cbor" },
|
|
132
|
+
body: dagCborEncode({
|
|
133
|
+
keys: [first, second],
|
|
134
|
+
maxValueBytes: 1024 * 1024,
|
|
135
|
+
}).slice(),
|
|
136
|
+
});
|
|
137
|
+
expect(oversizedResponse.status).toBe(400);
|
|
138
|
+
|
|
139
|
+
const settled = await Promise.allSettled([
|
|
140
|
+
storageBuckets.get(storageBucketEndpoint, first, small.length),
|
|
141
|
+
storageBuckets.get(storageBucketEndpoint, missing, small.length),
|
|
142
|
+
storageBuckets.get(storageBucketEndpoint, second, small.length),
|
|
143
|
+
]);
|
|
144
|
+
expect(settled[0]).toEqual({ status: "fulfilled", value: small });
|
|
145
|
+
expect(settled[1].status).toBe("rejected");
|
|
146
|
+
expect((settled[1] as PromiseRejectedResult).reason).toBeInstanceOf(GraffitiErrorNotFound);
|
|
147
|
+
expect(settled[2].status).toBe("rejected");
|
|
148
|
+
expect((settled[2] as PromiseRejectedResult).reason).toBeInstanceOf(GraffitiErrorTooLarge);
|
|
149
|
+
expect(await storageBuckets.get(storageBucketEndpoint, second)).toEqual(large);
|
|
150
|
+
} finally {
|
|
151
|
+
await storageBuckets.delete(storageBucketEndpoint, first, storageBucketToken);
|
|
152
|
+
await storageBuckets.delete(storageBucketEndpoint, second, storageBucketToken);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("accepts a full batch of maximum-length Unicode keys", async () => {
|
|
157
|
+
const key = "界".repeat(255);
|
|
158
|
+
const body = dagCborEncode({
|
|
159
|
+
keys: Array(32).fill(key),
|
|
160
|
+
maxValueBytes: 0,
|
|
161
|
+
}).slice();
|
|
162
|
+
expect(body.byteLength).toBeGreaterThan(16 * 1024);
|
|
163
|
+
const response = await fetch(`${storageBucketEndpoint}/values`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/cbor" },
|
|
166
|
+
body,
|
|
167
|
+
});
|
|
168
|
+
expect(response.status).toBe(200);
|
|
169
|
+
const decoded = dagCborDecode(await response.arrayBuffer()) as {
|
|
170
|
+
results: Array<{ status: number }>;
|
|
171
|
+
};
|
|
172
|
+
expect(decoded.results.map(({ status }) => status)).toEqual(Array(32).fill(404));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("concurrent bounded gets share one batch", async () => {
|
|
176
|
+
const keys = Array.from({ length: 3 }, () => crypto.randomUUID());
|
|
177
|
+
const values = keys.map((_, i) => new Uint8Array([i + 1]));
|
|
178
|
+
for (let i = 0; i < keys.length; i++) {
|
|
179
|
+
await storageBuckets.put(storageBucketEndpoint, keys[i], values[i], storageBucketToken);
|
|
180
|
+
}
|
|
181
|
+
const originalFetch = globalThis.fetch;
|
|
182
|
+
const calls: string[] = [];
|
|
183
|
+
globalThis.fetch = (input, init) => {
|
|
184
|
+
const request = new Request(input, init);
|
|
185
|
+
calls.push(`${request.method} ${request.url}`);
|
|
186
|
+
return originalFetch(input, init);
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
expect(await Promise.all(keys.map((key) =>
|
|
190
|
+
storageBuckets.get(storageBucketEndpoint, key, 1),
|
|
191
|
+
))).toEqual(values);
|
|
192
|
+
expect(calls.filter((call) => call === `POST ${storageBucketEndpoint}/values`)).toHaveLength(1);
|
|
193
|
+
expect(calls.filter((call) => call.includes("/value/"))).toHaveLength(0);
|
|
194
|
+
} finally {
|
|
195
|
+
globalThis.fetch = originalFetch;
|
|
196
|
+
for (const key of keys) {
|
|
197
|
+
await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("falls back to GET when a bucket server does not support batches", async () => {
|
|
203
|
+
const keys = Array.from({ length: 2 }, () => crypto.randomUUID());
|
|
204
|
+
const value = new Uint8Array([42]);
|
|
205
|
+
for (const key of keys) {
|
|
206
|
+
await storageBuckets.put(storageBucketEndpoint, key, value, storageBucketToken);
|
|
207
|
+
}
|
|
208
|
+
const originalFetch = globalThis.fetch;
|
|
209
|
+
let batchAttempts = 0;
|
|
210
|
+
globalThis.fetch = (input, init) => {
|
|
211
|
+
const request = new Request(input, init);
|
|
212
|
+
if (request.method === "POST" && request.url === `${storageBucketEndpoint}/values`) {
|
|
213
|
+
batchAttempts++;
|
|
214
|
+
return Promise.resolve(new Response("Not found", { status: 404 }));
|
|
215
|
+
}
|
|
216
|
+
return originalFetch(input, init);
|
|
217
|
+
};
|
|
218
|
+
try {
|
|
219
|
+
const legacyBuckets = new StorageBuckets();
|
|
220
|
+
expect(await Promise.all(keys.map((key) =>
|
|
221
|
+
legacyBuckets.get(storageBucketEndpoint, key, 1),
|
|
222
|
+
))).toEqual([value, value]);
|
|
223
|
+
expect(await Promise.all(keys.map((key) =>
|
|
224
|
+
legacyBuckets.get(storageBucketEndpoint, key, 1),
|
|
225
|
+
))).toEqual([value, value]);
|
|
226
|
+
expect(batchAttempts).toBe(1);
|
|
227
|
+
} finally {
|
|
228
|
+
globalThis.fetch = originalFetch;
|
|
229
|
+
for (const key of keys) {
|
|
230
|
+
await storageBuckets.delete(storageBucketEndpoint, key, storageBucketToken);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("a batch error does not disable batching or fall back to GET", async () => {
|
|
236
|
+
const originalFetch = globalThis.fetch;
|
|
237
|
+
let batchAttempts = 0;
|
|
238
|
+
let getAttempts = 0;
|
|
239
|
+
globalThis.fetch = (input, init) => {
|
|
240
|
+
const request = new Request(input, init);
|
|
241
|
+
if (request.method === "POST") {
|
|
242
|
+
batchAttempts++;
|
|
243
|
+
return Promise.resolve(new Response("Batch limit exceeded", { status: 413 }));
|
|
244
|
+
}
|
|
245
|
+
getAttempts++;
|
|
246
|
+
return originalFetch(input, init);
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
const buckets = new StorageBuckets();
|
|
250
|
+
const readPair = () => Promise.all([
|
|
251
|
+
buckets.get(storageBucketEndpoint, "a", 1),
|
|
252
|
+
buckets.get(storageBucketEndpoint, "b", 1),
|
|
253
|
+
]);
|
|
254
|
+
await expect(readPair()).rejects.toThrow("Batch limit exceeded");
|
|
255
|
+
await expect(readPair()).rejects.toThrow("Batch limit exceeded");
|
|
256
|
+
expect(batchAttempts).toBe(2);
|
|
257
|
+
expect(getAttempts).toBe(0);
|
|
258
|
+
} finally {
|
|
259
|
+
globalThis.fetch = originalFetch;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
65
263
|
test("unauthorized", async () => {
|
|
66
264
|
const key = Math.random().toString(36).substring(2, 15);
|
|
67
265
|
const input = "Hello world";
|
|
@@ -4,10 +4,33 @@ import {
|
|
|
4
4
|
verifyHTTPSEndpoint,
|
|
5
5
|
} from "./utilities";
|
|
6
6
|
import { string, array, object, optional, nullable } from "zod/mini";
|
|
7
|
-
import { decode as dagCborDecode } from "@ipld/dag-cbor";
|
|
7
|
+
import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
|
|
8
|
+
import { GraffitiErrorNotFound, GraffitiErrorTooLarge } from "@graffiti-garden/api";
|
|
9
|
+
|
|
10
|
+
// All batch-capable bucket servers accept these transport limits.
|
|
11
|
+
const BATCH_KEYS = 32; // total number of keys in a batch request
|
|
12
|
+
const BATCH_TOTAL_VALUE_BYTES = 1024 * 1024; // total response limit
|
|
13
|
+
|
|
14
|
+
// This is a heuristic for how long to wait to fill a batch request
|
|
15
|
+
// before sending it. Just enough waiting to capture a few more requests,
|
|
16
|
+
// but not so long that it adds noticeable latency.
|
|
17
|
+
const BATCH_WAIT_MS = 2;
|
|
18
|
+
|
|
19
|
+
function batchWidth(maxBytes: number) {
|
|
20
|
+
return Math.min(BATCH_KEYS, Math.floor(BATCH_TOTAL_VALUE_BYTES / maxBytes));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type PendingGet = {
|
|
24
|
+
key: string;
|
|
25
|
+
resolve: (value: Uint8Array) => void;
|
|
26
|
+
reject: (reason: unknown) => void;
|
|
27
|
+
};
|
|
28
|
+
type BatchQueue = { requests: PendingGet[]; timer?: ReturnType<typeof setTimeout> };
|
|
8
29
|
|
|
9
30
|
export class StorageBuckets {
|
|
10
31
|
getAuthorizationEndpoint = getAuthorizationEndpoint;
|
|
32
|
+
private readonly queues = new Map<string, Map<number, BatchQueue>>();
|
|
33
|
+
private readonly unsupportedBatchEndpoints = new Set<string>();
|
|
11
34
|
|
|
12
35
|
async put(
|
|
13
36
|
storageBucketEndpoint: string,
|
|
@@ -50,6 +73,111 @@ export class StorageBuckets {
|
|
|
50
73
|
maxBytes?: number,
|
|
51
74
|
): Promise<Uint8Array> {
|
|
52
75
|
verifyHTTPSEndpoint(storageBucketEndpoint);
|
|
76
|
+
if (
|
|
77
|
+
maxBytes !== undefined &&
|
|
78
|
+
Number.isSafeInteger(maxBytes) &&
|
|
79
|
+
maxBytes > 0 &&
|
|
80
|
+
batchWidth(maxBytes) >= 2 &&
|
|
81
|
+
!this.unsupportedBatchEndpoints.has(storageBucketEndpoint)
|
|
82
|
+
) {
|
|
83
|
+
return new Promise<Uint8Array>((resolve, reject) => {
|
|
84
|
+
let queuesByMaxBytes = this.queues.get(storageBucketEndpoint);
|
|
85
|
+
if (!queuesByMaxBytes) {
|
|
86
|
+
queuesByMaxBytes = new Map();
|
|
87
|
+
this.queues.set(storageBucketEndpoint, queuesByMaxBytes);
|
|
88
|
+
}
|
|
89
|
+
let queue = queuesByMaxBytes.get(maxBytes);
|
|
90
|
+
if (!queue) {
|
|
91
|
+
queue = { requests: [] };
|
|
92
|
+
queuesByMaxBytes.set(maxBytes, queue);
|
|
93
|
+
}
|
|
94
|
+
queue.requests.push({ key, resolve, reject });
|
|
95
|
+
if (queue.requests.length === batchWidth(maxBytes)) {
|
|
96
|
+
this.flush(storageBucketEndpoint, maxBytes, queue);
|
|
97
|
+
} else if (queue.timer === undefined) {
|
|
98
|
+
queue.timer = setTimeout(
|
|
99
|
+
() => this.flush(storageBucketEndpoint, maxBytes, queue),
|
|
100
|
+
BATCH_WAIT_MS,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return this.getOne(storageBucketEndpoint, key, maxBytes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private flush(endpoint: string, maxBytes: number, queue: BatchQueue) {
|
|
109
|
+
if (queue.timer !== undefined) clearTimeout(queue.timer);
|
|
110
|
+
const requests = queue.requests;
|
|
111
|
+
const queuesByMaxBytes = this.queues.get(endpoint)!;
|
|
112
|
+
queuesByMaxBytes.delete(maxBytes);
|
|
113
|
+
if (queuesByMaxBytes.size === 0) this.queues.delete(endpoint);
|
|
114
|
+
if (requests.length === 1 || this.unsupportedBatchEndpoints.has(endpoint)) {
|
|
115
|
+
requests.forEach(({ key, resolve, reject }) => {
|
|
116
|
+
void this.getOne(endpoint, key, maxBytes).then(resolve, reject);
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
void this.getBatch(endpoint, maxBytes, requests);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async getBatch(endpoint: string, maxBytes: number, requests: PendingGet[]) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`${endpoint}/values`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "Content-Type": "application/cbor", Accept: "application/cbor" },
|
|
128
|
+
body: dagCborEncode({
|
|
129
|
+
keys: requests.map(({ key }) => key),
|
|
130
|
+
maxValueBytes: maxBytes,
|
|
131
|
+
}).slice(),
|
|
132
|
+
});
|
|
133
|
+
if (response.status === 404) {
|
|
134
|
+
// An older bucket server has no /values route but still serves GETs.
|
|
135
|
+
this.unsupportedBatchEndpoints.add(endpoint);
|
|
136
|
+
await Promise.all(requests.map(async ({ key, resolve, reject }) => {
|
|
137
|
+
try {
|
|
138
|
+
resolve(await this.getOne(endpoint, key, maxBytes));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
reject(error);
|
|
141
|
+
}
|
|
142
|
+
}));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!response.ok) throw new Error(await response.text());
|
|
146
|
+
const decoded = dagCborDecode(await response.arrayBuffer());
|
|
147
|
+
if (
|
|
148
|
+
!decoded || typeof decoded !== "object" ||
|
|
149
|
+
!("results" in decoded) || !Array.isArray(decoded.results) ||
|
|
150
|
+
decoded.results.length !== requests.length
|
|
151
|
+
) throw new Error("Invalid storage batch response");
|
|
152
|
+
|
|
153
|
+
decoded.results.forEach((result: unknown, index: number) => {
|
|
154
|
+
const { resolve, reject } = requests[index];
|
|
155
|
+
if (!result || typeof result !== "object" || !("status" in result)) {
|
|
156
|
+
reject(new Error("Invalid storage batch result"));
|
|
157
|
+
} else if (
|
|
158
|
+
result.status === 200 && "value" in result &&
|
|
159
|
+
result.value instanceof Uint8Array &&
|
|
160
|
+
result.value.byteLength <= maxBytes
|
|
161
|
+
) {
|
|
162
|
+
resolve(result.value);
|
|
163
|
+
} else if (result.status === 404) {
|
|
164
|
+
reject(new GraffitiErrorNotFound("Value not found"));
|
|
165
|
+
} else if (result.status === 413) {
|
|
166
|
+
reject(new GraffitiErrorTooLarge("Value exceeds maximum byte limit"));
|
|
167
|
+
} else {
|
|
168
|
+
reject(new Error("Invalid storage batch result"));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
requests.forEach(({ reject }) => reject(error));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async getOne(
|
|
177
|
+
storageBucketEndpoint: string,
|
|
178
|
+
key: string,
|
|
179
|
+
maxBytes?: number,
|
|
180
|
+
): Promise<Uint8Array> {
|
|
53
181
|
const url = `${storageBucketEndpoint}/value/${encodeURIComponent(key)}`;
|
|
54
182
|
|
|
55
183
|
const response = await fetchWithErrorHandling(url);
|
|
@@ -3,6 +3,7 @@ import { Inboxes, LABELED_MESSAGE_LABEL_KEY } from "./4-inboxes";
|
|
|
3
3
|
import { GraffitiErrorUnauthorized } from "./utilities";
|
|
4
4
|
import { randomBytes } from "@noble/hashes/utils.js";
|
|
5
5
|
import type { GraffitiObjectBase } from "@graffiti-garden/api";
|
|
6
|
+
import { encode as dagCborEncode, decode as dagCborDecode } from "@ipld/dag-cbor";
|
|
6
7
|
|
|
7
8
|
export function inboxTests(inboxEndpoint: string, inboxToken: string) {
|
|
8
9
|
describe("Inboxes", async () => {
|
|
@@ -138,6 +139,30 @@ export function inboxTests(inboxEndpoint: string, inboxToken: string) {
|
|
|
138
139
|
});
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
let cursor: string | undefined;
|
|
143
|
+
for (const [page, expectedCount] of [10, 100, 101].entries()) {
|
|
144
|
+
const response = await fetch(
|
|
145
|
+
`${inboxEndpoint}/query${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`,
|
|
146
|
+
{
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
"Content-Type": "application/cbor",
|
|
150
|
+
Authorization: `Bearer ${inboxToken}`,
|
|
151
|
+
},
|
|
152
|
+
body: cursor ? undefined : dagCborEncode({ tags: [tags[1]], schema: {} }).slice(),
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
expect(response.status).toBe(200);
|
|
156
|
+
const pageResult = dagCborDecode(new Uint8Array(await response.arrayBuffer())) as {
|
|
157
|
+
results: unknown[];
|
|
158
|
+
hasMore: boolean;
|
|
159
|
+
cursor: string;
|
|
160
|
+
};
|
|
161
|
+
expect(pageResult.results).toHaveLength(expectedCount);
|
|
162
|
+
expect(pageResult.hasMore).toBe(page < 2);
|
|
163
|
+
cursor = pageResult.cursor;
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
const iterator = inboxes.query(
|
|
142
167
|
inboxEndpoint,
|
|
143
168
|
[randomBytes(), tags[1], randomBytes()],
|