@graffiti-garden/implementation-decentralized 0.1.0 → 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.
Files changed (44) hide show
  1. package/dist/1-services/2-dids.d.ts +1 -0
  2. package/dist/1-services/2-dids.d.ts.map +1 -1
  3. package/dist/1-services/3-storage-buckets.d.ts +5 -0
  4. package/dist/1-services/3-storage-buckets.d.ts.map +1 -1
  5. package/dist/1-services/4-inboxes.d.ts +2 -1
  6. package/dist/1-services/4-inboxes.d.ts.map +1 -1
  7. package/dist/browser/index.js +7 -7
  8. package/dist/browser/index.js.map +3 -3
  9. package/dist/cjs/1-services/2-dids-tests.js +53 -0
  10. package/dist/cjs/1-services/2-dids-tests.js.map +3 -3
  11. package/dist/cjs/1-services/2-dids.js +14 -4
  12. package/dist/cjs/1-services/2-dids.js.map +2 -2
  13. package/dist/cjs/1-services/3-storage-buckets-tests.js +179 -0
  14. package/dist/cjs/1-services/3-storage-buckets-tests.js.map +3 -3
  15. package/dist/cjs/1-services/3-storage-buckets.js +91 -0
  16. package/dist/cjs/1-services/3-storage-buckets.js.map +3 -3
  17. package/dist/cjs/1-services/4-inboxes-tests.js +20 -0
  18. package/dist/cjs/1-services/4-inboxes-tests.js.map +3 -3
  19. package/dist/cjs/1-services/4-inboxes.js +36 -17
  20. package/dist/cjs/1-services/4-inboxes.js.map +2 -2
  21. package/dist/cjs/3-protocol/4-graffiti.js +1 -1
  22. package/dist/cjs/3-protocol/4-graffiti.js.map +1 -1
  23. package/dist/esm/1-services/2-dids-tests.js +54 -1
  24. package/dist/esm/1-services/2-dids-tests.js.map +3 -3
  25. package/dist/esm/1-services/2-dids.js +14 -4
  26. package/dist/esm/1-services/2-dids.js.map +2 -2
  27. package/dist/esm/1-services/3-storage-buckets-tests.js +180 -1
  28. package/dist/esm/1-services/3-storage-buckets-tests.js.map +2 -2
  29. package/dist/esm/1-services/3-storage-buckets.js +92 -1
  30. package/dist/esm/1-services/3-storage-buckets.js.map +2 -2
  31. package/dist/esm/1-services/4-inboxes-tests.js +20 -0
  32. package/dist/esm/1-services/4-inboxes-tests.js.map +2 -2
  33. package/dist/esm/1-services/4-inboxes.js +36 -17
  34. package/dist/esm/1-services/4-inboxes.js.map +2 -2
  35. package/dist/esm/3-protocol/4-graffiti.js +1 -1
  36. package/dist/esm/3-protocol/4-graffiti.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/1-services/2-dids-tests.ts +62 -1
  39. package/src/1-services/2-dids.ts +17 -5
  40. package/src/1-services/3-storage-buckets-tests.ts +199 -1
  41. package/src/1-services/3-storage-buckets.ts +129 -1
  42. package/src/1-services/4-inboxes-tests.ts +25 -0
  43. package/src/1-services/4-inboxes.ts +47 -19
  44. package/src/3-protocol/4-graffiti.ts +1 -1
@@ -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()],
@@ -138,6 +138,7 @@ export class Inboxes {
138
138
  body: Uint8Array<ArrayBuffer> | undefined,
139
139
  inboxToken?: string | null,
140
140
  cursor?: string,
141
+ signal?: AbortSignal,
141
142
  ) {
142
143
  const response = await fetchWithErrorHandling(
143
144
  `${inboxUrl}/${type}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`,
@@ -152,6 +153,7 @@ export class Inboxes {
152
153
  : {}),
153
154
  },
154
155
  body,
156
+ signal,
155
157
  },
156
158
  );
157
159
  const retryAfterHeader = response.headers.get("Retry-After");
@@ -414,13 +416,11 @@ export class Inboxes {
414
416
  );
415
417
 
416
418
  // First cache the messages with their labels
417
- await Promise.all(
418
- labeledMessages.map((m: LabeledMessageBase) =>
419
- cache.messages.set(
420
- getMessageCacheKey(inboxUrl, m[LABELED_MESSAGE_ID_KEY]),
421
- m,
422
- ),
423
- ),
419
+ await cache.messages.setMany(
420
+ labeledMessages.map((m: LabeledMessageBase) => [
421
+ getMessageCacheKey(inboxUrl, m[LABELED_MESSAGE_ID_KEY]),
422
+ m,
423
+ ]),
424
424
  );
425
425
  // Then store all the messageids
426
426
  messageIds = [
@@ -439,22 +439,38 @@ export class Inboxes {
439
439
  // Update how many we've seen
440
440
  cacheNumSeen += labeledMessages.length;
441
441
 
442
+ // Fetch the next page while the caller processes the current page.
443
+ const prefetchController = new AbortController();
444
+ // Otherwise get another response (after waiting for rate-limit)
445
+ const nextPage = hasMore
446
+ ? waitFor(waitTil)
447
+ .then(() => this.fetchMessageBatch(
448
+ inboxUrl,
449
+ type,
450
+ undefined, // Body is never past the first time
451
+ inboxToken,
452
+ cursor,
453
+ prefetchController.signal,
454
+ ))
455
+ // A consumer may stop before awaiting this request.
456
+ .then((value) => ({ value }), (error) => ({ error }))
457
+ : null;
458
+
442
459
  // Return the values
443
- for (const m of labeledMessages) yield m;
460
+ let consumedPage = false;
461
+ try {
462
+ for (const m of labeledMessages) yield m;
463
+ consumedPage = true;
464
+ } finally {
465
+ if (!consumedPage) prefetchController.abort();
466
+ }
444
467
 
445
468
  if (!hasMore) break;
446
469
 
447
- // Otherwise get another response (after waiting for rate-limit)
448
- await waitFor(waitTil);
449
- const out = await this.fetchMessageBatch(
450
- inboxUrl,
451
- type,
452
- undefined, // Body is never past the first time
453
- inboxToken,
454
- cursor,
455
- );
456
- response = out.response;
457
- waitTil = out.waitTil;
470
+ const out = await nextPage!;
471
+ if ("error" in out) throw out.error;
472
+ response = out.value.response;
473
+ waitTil = out.value.waitTil;
458
474
  }
459
475
 
460
476
  const outputCursor: infer_<typeof CursorSchema> = {
@@ -619,6 +635,7 @@ type Cache = {
619
635
  messages: {
620
636
  get(k: string): Promise<LabeledMessageBase | null | undefined>;
621
637
  set(k: string, value: LabeledMessageBase | null): Promise<void>;
638
+ setMany(entries: [string, LabeledMessageBase][]): Promise<void>;
622
639
  del(k: string): Promise<void>;
623
640
  };
624
641
  messageIds: {
@@ -684,6 +701,14 @@ async function createCache(): Promise<Cache> {
684
701
  set: async (k, v) => {
685
702
  await db.put("m", v, k);
686
703
  },
704
+ setMany: async (entries) => {
705
+ if (entries.length === 0) return;
706
+ const tx = db.transaction("m", "readwrite");
707
+ await Promise.all([
708
+ ...entries.map(([k, v]) => tx.store.put(v, k)),
709
+ tx.done,
710
+ ]);
711
+ },
687
712
  del: (k) => db.delete("m", k),
688
713
  },
689
714
  messageIds: {
@@ -712,6 +737,9 @@ async function createCache(): Promise<Cache> {
712
737
  messages: {
713
738
  get: async (k) => m.get(k),
714
739
  set: async (k, v) => void m.set(k, v),
740
+ setMany: async (entries) => {
741
+ for (const [k, v] of entries) m.set(k, v);
742
+ },
715
743
  del: async (k) => void m.delete(k),
716
744
  },
717
745
  messageIds: {
@@ -133,7 +133,7 @@ export interface GraffitiDecentralizedOptions {
133
133
  defaultInboxEndpoints?: string[];
134
134
  }
135
135
 
136
- const CONCURRENCY = 16;
136
+ const CONCURRENCY = 128;
137
137
  const LOCAL_STORAGE_REMEMBERED_HANDLE_KEY = "graffiti-login-remembered-handle";
138
138
 
139
139
  export class GraffitiDecentralized implements Graffiti {