@graffiti-garden/implementation-decentralized 0.1.2 → 0.1.4

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 (61) hide show
  1. package/dist/1-services/4-inboxes/cache.d.ts +34 -0
  2. package/dist/1-services/4-inboxes/cache.d.ts.map +1 -0
  3. package/dist/1-services/4-inboxes/messages.d.ts +70 -0
  4. package/dist/1-services/4-inboxes/messages.d.ts.map +1 -0
  5. package/dist/1-services/4-inboxes.d.ts +28 -67
  6. package/dist/1-services/4-inboxes.d.ts.map +1 -1
  7. package/dist/3-protocol/2-handles.d.ts.map +1 -1
  8. package/dist/3-protocol/4-graffiti.d.ts +2 -2
  9. package/dist/3-protocol/4-graffiti.d.ts.map +1 -1
  10. package/dist/3-protocol/login-dialog.html.d.ts +1 -1
  11. package/dist/3-protocol/login-dialog.html.d.ts.map +1 -1
  12. package/dist/browser/index.js +7 -7
  13. package/dist/browser/index.js.map +4 -4
  14. package/dist/browser/{login-dialog.html-E6VRKMXH.js → login-dialog.html-WIDO3XZF.js} +18 -4
  15. package/dist/browser/{login-dialog.html-E6VRKMXH.js.map → login-dialog.html-WIDO3XZF.js.map} +2 -2
  16. package/dist/cjs/1-services/4-inboxes/cache.js +186 -0
  17. package/dist/cjs/1-services/4-inboxes/cache.js.map +7 -0
  18. package/dist/cjs/1-services/4-inboxes/messages.js +69 -0
  19. package/dist/cjs/1-services/4-inboxes/messages.js.map +7 -0
  20. package/dist/cjs/1-services/4-inboxes-tests.js +339 -9
  21. package/dist/cjs/1-services/4-inboxes-tests.js.map +3 -3
  22. package/dist/cjs/1-services/4-inboxes.js +210 -399
  23. package/dist/cjs/1-services/4-inboxes.js.map +3 -3
  24. package/dist/cjs/3-protocol/2-handles-tests.js +8 -0
  25. package/dist/cjs/3-protocol/2-handles-tests.js.map +2 -2
  26. package/dist/cjs/3-protocol/2-handles.js +3 -4
  27. package/dist/cjs/3-protocol/2-handles.js.map +2 -2
  28. package/dist/cjs/3-protocol/4-graffiti.js +49 -53
  29. package/dist/cjs/3-protocol/4-graffiti.js.map +2 -2
  30. package/dist/cjs/3-protocol/login-dialog.html.js +17 -3
  31. package/dist/cjs/3-protocol/login-dialog.html.js.map +2 -2
  32. package/dist/cjs/index.spec.js +90 -0
  33. package/dist/cjs/index.spec.js.map +3 -3
  34. package/dist/esm/1-services/4-inboxes/cache.js +156 -0
  35. package/dist/esm/1-services/4-inboxes/cache.js.map +7 -0
  36. package/dist/esm/1-services/4-inboxes/messages.js +60 -0
  37. package/dist/esm/1-services/4-inboxes/messages.js.map +7 -0
  38. package/dist/esm/1-services/4-inboxes-tests.js +340 -10
  39. package/dist/esm/1-services/4-inboxes-tests.js.map +3 -3
  40. package/dist/esm/1-services/4-inboxes.js +222 -383
  41. package/dist/esm/1-services/4-inboxes.js.map +3 -3
  42. package/dist/esm/3-protocol/2-handles-tests.js +8 -0
  43. package/dist/esm/3-protocol/2-handles-tests.js.map +2 -2
  44. package/dist/esm/3-protocol/2-handles.js +3 -4
  45. package/dist/esm/3-protocol/2-handles.js.map +2 -2
  46. package/dist/esm/3-protocol/4-graffiti.js +49 -53
  47. package/dist/esm/3-protocol/4-graffiti.js.map +2 -2
  48. package/dist/esm/3-protocol/login-dialog.html.js +17 -3
  49. package/dist/esm/3-protocol/login-dialog.html.js.map +2 -2
  50. package/dist/esm/index.spec.js +92 -2
  51. package/dist/esm/index.spec.js.map +2 -2
  52. package/package.json +1 -1
  53. package/src/1-services/4-inboxes/cache.ts +227 -0
  54. package/src/1-services/4-inboxes/messages.ts +63 -0
  55. package/src/1-services/4-inboxes-tests.ts +387 -2
  56. package/src/1-services/4-inboxes.ts +297 -512
  57. package/src/3-protocol/2-handles-tests.ts +12 -0
  58. package/src/3-protocol/2-handles.ts +3 -5
  59. package/src/3-protocol/4-graffiti.ts +69 -83
  60. package/src/3-protocol/login-dialog.html.ts +17 -3
  61. package/src/index.spec.ts +106 -2
@@ -1,4 +1,4 @@
1
- import type { JSONSchema, GraffitiObject } from "@graffiti-garden/api";
1
+ import type { JSONSchema } from "@graffiti-garden/api";
2
2
  import {
3
3
  getAuthorizationEndpoint,
4
4
  fetchWithErrorHandling,
@@ -16,19 +16,75 @@ import {
16
16
  import {
17
17
  type infer as infer_,
18
18
  string,
19
- url,
20
- array,
21
- optional,
22
- nullable,
23
19
  strictObject,
24
20
  looseObject,
25
21
  nonnegative,
26
22
  int,
27
23
  boolean,
28
- custom,
29
- number,
30
24
  union,
31
25
  } from "zod/mini";
26
+ import {
27
+ LabeledMessageBaseSchema,
28
+ SendResponseSchema,
29
+ MessageResultSchema,
30
+ LABELED_MESSAGE_MESSAGE_KEY,
31
+ MESSAGE_OBJECT_KEY,
32
+ } from "./4-inboxes/messages";
33
+ import type {
34
+ LabeledMessageBase,
35
+ LabeledMessage,
36
+ Message,
37
+ } from "./4-inboxes/messages";
38
+ import {
39
+ canUseIDB,
40
+ createCache,
41
+ getMessageCacheKey,
42
+ getMessageIdsCacheKey,
43
+ replayQuery,
44
+ storeQueryPage,
45
+ } from "./4-inboxes/cache";
46
+ import type { Cache, CacheQueryValue } from "./4-inboxes/cache";
47
+
48
+ export {
49
+ Uint8ArraySchema,
50
+ TagsSchema,
51
+ MESSAGE_TAGS_KEY,
52
+ MESSAGE_OBJECT_KEY,
53
+ MESSAGE_METADATA_KEY,
54
+ MessageBaseSchema,
55
+ LABELED_MESSAGE_ID_KEY,
56
+ LABELED_MESSAGE_MESSAGE_KEY,
57
+ LABELED_MESSAGE_LABEL_KEY,
58
+ LabeledMessageBaseSchema,
59
+ } from "./4-inboxes/messages";
60
+ export type { Message, LabeledMessage } from "./4-inboxes/messages";
61
+
62
+ // One batch may be slow on a mobile connection. This bounds the network request
63
+ // and response-body read, not IndexedDB or time spent yielding messages.
64
+ const INBOX_REQUEST_TIMEOUT_MS = 30_000;
65
+ // A stalled tab should not make peers wait indefinitely. This only bounds
66
+ // lock acquisition; the request above bounds work after acquisition.
67
+ const INBOX_LOCK_WAIT_TIMEOUT_MS = 30_000;
68
+
69
+ type StreamSource<Schema extends JSONSchema> =
70
+ | {
71
+ kind: "query";
72
+ inboxUrl: string;
73
+ body: Uint8Array<ArrayBuffer>;
74
+ schema: Schema;
75
+ inboxToken?: string | null;
76
+ }
77
+ | {
78
+ kind: "continue";
79
+ inboxUrl: string;
80
+ cursor: infer_<typeof CursorSchema>;
81
+ inboxToken?: string | null;
82
+ }
83
+ | {
84
+ kind: "export";
85
+ inboxUrl: string;
86
+ inboxToken: string;
87
+ };
32
88
 
33
89
  export class Inboxes {
34
90
  getAuthorizationEndpoint = getAuthorizationEndpoint;
@@ -140,6 +196,8 @@ export class Inboxes {
140
196
  cursor?: string,
141
197
  signal?: AbortSignal,
142
198
  ) {
199
+ // Keep the deadline active while the response body is being read, too.
200
+ const timeout = AbortSignal.timeout(INBOX_REQUEST_TIMEOUT_MS);
143
201
  const response = await fetchWithErrorHandling(
144
202
  `${inboxUrl}/${type}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`,
145
203
  {
@@ -153,7 +211,7 @@ export class Inboxes {
153
211
  : {}),
154
212
  },
155
213
  body,
156
- signal,
214
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
157
215
  },
158
216
  );
159
217
  const retryAfterHeader = response.headers.get("Retry-After");
@@ -169,318 +227,254 @@ export class Inboxes {
169
227
  return { response, waitTil };
170
228
  }
171
229
 
172
- protected async *yieldFromCache(
173
- cache: Cache,
174
- inboxUrl: string,
230
+ protected async withQueryLock<T>(
175
231
  messageIdsCacheKey: string,
176
- cachedMessageIds: CacheQueryValue,
177
- cacheNumSeen: number = 0,
178
- ): AsyncGenerator<LabeledMessageBase> {
179
- // Filter out all messageIds before
180
- // the number already seen
181
- const messageIds = cachedMessageIds.messageIds.slice(cacheNumSeen);
182
-
183
- // Get all the messages pointed to in the cache
184
- const messages = await Promise.all(
185
- messageIds.map(async (id) => {
186
- const message = await cache.messages.get(
187
- getMessageCacheKey(inboxUrl, id),
188
- );
189
- if (!message) {
190
- // Something is very wrong with the cache,
191
- // it refers to message IDs that are not cached
192
- try {
193
- await cache.messageIds.del(messageIdsCacheKey);
194
- } catch {}
195
- throw new Error("Cache out of sync - perhaps clear browser storage");
196
- }
197
- return message;
198
- }),
199
- );
200
-
201
- yield* messages;
202
- }
203
-
204
- protected async *lockedMessageStreamer<Schema extends JSONSchema>(
205
- ...args: Parameters<typeof this.messageStreamer<Schema>>
206
- ): MessageStream<Schema> {
207
- if (typeof window === "undefined" || !(await canUseIDB())) {
208
- // TODO: implement locking in node as well, but not
209
- // high priority since most use will be in browser
210
- const streamer = this.messageStreamer<Schema>(...args);
211
- while (true) {
212
- const next = await streamer.next();
213
- if (next.done) return next.value;
214
- yield next.value;
215
- }
216
- }
232
+ useLock: boolean,
233
+ work: () => Promise<T>,
234
+ signal?: AbortSignal,
235
+ ): Promise<T> {
236
+ if (!useLock) return work();
217
237
 
218
- // Request the lock
219
- const messageIdsCacheKey = await args[0];
220
- const lockKey = `graffiti:inbox:${messageIdsCacheKey}`;
221
- let releaseLock = () => {};
222
- let hasLock: boolean = false;
223
- await new Promise<void>((resolvehasLock) => {
224
- window.navigator.locks.request(
225
- lockKey,
238
+ // The lock follows one page of network and cache work; yielding cannot
239
+ // prolong it.
240
+ const timeout = AbortSignal.timeout(INBOX_LOCK_WAIT_TIMEOUT_MS);
241
+ let acquired = false;
242
+ try {
243
+ return await window.navigator.locks.request(
244
+ `graffiti:inbox:${messageIdsCacheKey}`,
226
245
  {
227
246
  mode: "exclusive",
228
- ifAvailable: true,
247
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
229
248
  },
230
- async (lock) => {
231
- // Immediately return whether we
232
- // acquired the lock or not
233
- hasLock = !!lock;
234
- resolvehasLock();
235
-
236
- // Then wait for the release to be called
237
- await new Promise<void>((r) => (releaseLock = r));
249
+ async () => {
250
+ acquired = true;
251
+ return work();
238
252
  },
239
253
  );
240
- });
241
- if (hasLock) {
242
- // If we have the lock, simply proceed with the regular streamer
243
- try {
244
- const streamer = this.messageStreamer<Schema>(...args);
245
- while (true) {
246
- const next = await streamer.next();
247
- if (next.done) return next.value;
248
- yield next.value;
249
- }
250
- } finally {
251
- // Release the lock when all done
252
- releaseLock();
254
+ } catch (error) {
255
+ if (!acquired && timeout.aborted) {
256
+ throw new Error("Timed out waiting for another tab's inbox query", {
257
+ cause: error,
258
+ });
253
259
  }
260
+ throw error;
254
261
  }
255
-
256
- // Someone else has the lock,
257
- // so wait until the lock is released,
258
- // then just return from the cache
259
- releaseLock();
260
- await window.navigator.locks.request(lockKey, () => {});
261
-
262
- // TODO: the arguments here are brittle
263
- // at some point, refactor things
264
- const inboxUrl = args[1];
265
- const objectSchema = args[5] ?? {};
266
- const cacheVersion = args[6];
267
- const cacheNumSeen = args[7];
268
-
269
- const cache = await this.cache;
270
- const cachedMessageIds = await cache.messageIds.get(messageIdsCacheKey);
271
- if (!cachedMessageIds) {
272
- throw new Error("Cache not found");
273
- }
274
- if (
275
- cacheVersion !== undefined &&
276
- cacheVersion !== cachedMessageIds.version
277
- ) {
278
- throw new GraffitiErrorCursorExpired("Cursor is stale");
279
- }
280
-
281
- const iterator = this.yieldFromCache(
282
- cache,
283
- inboxUrl,
284
- messageIdsCacheKey,
285
- cachedMessageIds,
286
- cacheNumSeen,
287
- );
288
- for await (const m of iterator) yield m as LabeledMessage<Schema>;
289
-
290
- const outputCursor: infer_<typeof CursorSchema> = {
291
- numSeen: cachedMessageIds.messageIds.length,
292
- version: cachedMessageIds.version,
293
- messageIdsCacheKey,
294
- objectSchema,
295
- };
296
-
297
- return JSON.stringify(outputCursor);
298
262
  }
299
263
 
300
264
  protected async *messageStreamer<Schema extends JSONSchema>(
301
- messageIdsCacheKey_: Promise<string>,
302
- inboxUrl: string,
303
- type: "export" | "query",
304
- body: Uint8Array<ArrayBuffer> | undefined,
305
- inboxToken?: string | null,
306
- objectSchema: Schema = {} as Schema,
307
- cacheVersion?: string,
308
- cacheNumSeen: number = 0,
265
+ source: StreamSource<Schema>,
309
266
  ): MessageStream<Schema> {
267
+ const { inboxUrl, inboxToken } = source;
268
+ const type = source.kind === "export" ? "export" : "query";
269
+ // Continuations carry their validated schema in the cursor; export uses
270
+ // the empty schema. Both callers instantiate this stream with Schema = {}.
271
+ const objectSchema = (
272
+ source.kind === "query"
273
+ ? source.schema
274
+ : source.kind === "continue" ? source.cursor.objectSchema : {}
275
+ ) as Schema;
310
276
  const validator = await compileGraffitiObjectSchema(objectSchema);
311
- const messageIdsCacheKey = await messageIdsCacheKey_;
277
+ const body = source.kind === "query" ? source.body : undefined;
278
+ const messageIdsCacheKey = source.kind === "continue"
279
+ ? source.cursor.messageIdsCacheKey
280
+ : await getMessageIdsCacheKey(inboxUrl, type, body);
281
+ const cacheVersion = source.kind === "continue"
282
+ ? source.cursor.version
283
+ : undefined;
284
+ // Opening IndexedDB can wait on another tab, so do it before holding a lock.
312
285
  const cache = await this.cache;
313
-
314
- let cachedMessageIds = await cache.messageIds.get(messageIdsCacheKey);
315
- if (
316
- cacheVersion !== undefined &&
317
- cacheVersion !== cachedMessageIds?.version
318
- ) {
319
- throw new GraffitiErrorCursorExpired("Cursor is stale");
320
- }
321
-
322
- // If we are rate-limited, wait
323
- let waitTil = cachedMessageIds?.waitTil;
324
- await waitFor(waitTil);
325
-
326
- // See if the cursor is still active by
327
- // requesting an initial batch of messages
328
- const cachedCursor = cachedMessageIds?.cursor;
329
- let firstResponse: Response | undefined = undefined;
330
- try {
331
- const out = await this.fetchMessageBatch(
332
- inboxUrl,
333
- type,
334
- body,
335
- inboxToken,
336
- cachedCursor,
337
- );
338
- firstResponse = out.response;
339
- waitTil = out.waitTil;
340
- } catch (e) {
341
- if (!(e instanceof GraffitiErrorCursorExpired && cachedCursor)) {
342
- console.error(
343
- "Unexpected error in stream, waiting 5 seconds before continuing...",
286
+ // TODO: implement locking in node as well, but not
287
+ // high priority since most use will be in browser
288
+ const useLock = typeof window !== "undefined" && (await canUseIDB());
289
+ const initialQuery = await cache.messageIds.get(messageIdsCacheKey);
290
+ // Version identifies a query history; generation changes on each page
291
+ // written to that history, including an empty page.
292
+ let seenGeneration = initialQuery?.generation;
293
+ let seenVersion = initialQuery?.version;
294
+ let numSeen = source.kind === "continue" ? source.cursor.numSeen : 0;
295
+ let firstPage = true;
296
+
297
+ // Advance one page at most. Recheck the cache under the lock so a tab
298
+ // waiting for another tab's fetch can reuse its result.
299
+ const fetchPage = async (signal?: AbortSignal): Promise<CacheQueryValue> => {
300
+ while (true) {
301
+ signal?.throwIfAborted();
302
+ const step = await this.withQueryLock(
303
+ messageIdsCacheKey,
304
+ useLock,
305
+ async () => {
306
+ signal?.throwIfAborted();
307
+ // A different tab may have fetched a page while we waited for the lock.
308
+ let query = await cache.messageIds.get(messageIdsCacheKey);
309
+ if (cacheVersion !== undefined && cacheVersion !== query?.version) {
310
+ throw new GraffitiErrorCursorExpired("Cursor is stale");
311
+ }
312
+ if (query?.version !== seenVersion) {
313
+ // A cache reset after this stream yielded messages would replay
314
+ // them. Report the stale cursor as a midstream expiry instead.
315
+ if (!firstPage) throw new GraffitiErrorCursorExpired("Cursor is stale");
316
+ numSeen = 0;
317
+ }
318
+ if (query?.generation && query.generation !== seenGeneration) {
319
+ return { query };
320
+ }
321
+
322
+ // A cached Retry-After should not keep other tabs waiting on this lock.
323
+ if (query?.waitTil !== undefined && query.waitTil > Date.now()) {
324
+ return { waitTil: query.waitTil };
325
+ }
326
+
327
+ // See if the cursor is still active by requesting the next batch.
328
+ let batch: { response: Response; waitTil?: number };
329
+ try {
330
+ batch = await this.fetchMessageBatch(
331
+ inboxUrl,
332
+ type,
333
+ firstPage ? body : undefined, // Later pages send no body.
334
+ inboxToken,
335
+ query?.cursor,
336
+ signal,
337
+ );
338
+ } catch (error) {
339
+ if (
340
+ !(error instanceof GraffitiErrorCursorExpired && query?.cursor)
341
+ ) {
342
+ // Preserve the old initial-page error backoff, but wait only
343
+ // after releasing the Web Lock.
344
+ if (firstPage && !signal?.aborted) {
345
+ return { initialFetchError: error };
346
+ }
347
+ throw error;
348
+ }
349
+ // Only an initial cached cursor is retried. A cursor that
350
+ // expires midstream must be reported to the caller.
351
+ if (!firstPage) throw error;
352
+
353
+ // The cursor is stale. A continuation must report that to its caller.
354
+ await cache.messageIds.del(messageIdsCacheKey);
355
+ if (cacheVersion !== undefined) throw error;
356
+ query = undefined;
357
+ numSeen = 0;
358
+ batch = await this.fetchMessageBatch(
359
+ inboxUrl,
360
+ type,
361
+ body,
362
+ inboxToken,
363
+ undefined,
364
+ signal,
365
+ );
366
+ }
367
+
368
+ const decoded = dagCborDecode(
369
+ new Uint8Array(await batch.response.arrayBuffer()),
370
+ );
371
+ const { results, hasMore, cursor } =
372
+ MessageResultSchema.parse(decoded);
373
+ const labeledMessages: LabeledMessage<Schema>[] = results.map(
374
+ (result) => {
375
+ const object =
376
+ result[LABELED_MESSAGE_MESSAGE_KEY][MESSAGE_OBJECT_KEY];
377
+ if (!validator(object)) {
378
+ throw new Error("Server returned data that does not match schema");
379
+ }
380
+ return {
381
+ ...result,
382
+ [LABELED_MESSAGE_MESSAGE_KEY]: {
383
+ ...result[LABELED_MESSAGE_MESSAGE_KEY],
384
+ [MESSAGE_OBJECT_KEY]: object,
385
+ },
386
+ };
387
+ },
388
+ );
389
+
390
+ query = await storeQueryPage(
391
+ cache,
392
+ inboxUrl,
393
+ messageIdsCacheKey,
394
+ query,
395
+ {
396
+ messages: labeledMessages,
397
+ cursor,
398
+ hasMore,
399
+ waitTil: batch.waitTil,
400
+ },
401
+ );
402
+ return { query };
403
+ },
404
+ signal,
344
405
  );
345
- await new Promise((resolve) => setTimeout(resolve, 5000));
346
- throw e;
347
- }
348
406
 
349
- // The cursor is stale
350
- await cache.messageIds.del(messageIdsCacheKey);
351
- if (cacheVersion === undefined) {
352
- // The query is not a continuation
353
- // so we can effectively ignore the error
354
- cachedMessageIds = undefined;
355
- } else {
356
- // Otherwise propogate it up so the
357
- // consumer can clear their message history
358
- throw e;
407
+ if ("initialFetchError" in step) {
408
+ console.error(
409
+ "Unexpected error in stream, waiting 5 seconds before continuing...",
410
+ );
411
+ await new Promise((resolve) => setTimeout(resolve, 5000));
412
+ throw step.initialFetchError;
413
+ }
414
+ if ("waitTil" in step) {
415
+ await waitFor(step.waitTil);
416
+ continue;
417
+ }
418
+ return step.query;
359
419
  }
360
- }
420
+ };
361
421
 
362
- if (firstResponse !== undefined && cachedMessageIds) {
363
- // Cursor is valid! Yield from the cache
364
- const iterator = this.yieldFromCache(
422
+ type PageResult = { query: CacheQueryValue } | { error: unknown };
423
+ let nextPage: Promise<PageResult> | undefined;
424
+ while (true) {
425
+ const result = nextPage
426
+ ? await nextPage
427
+ : await fetchPage().then(
428
+ (query) => ({ query }),
429
+ (error) => ({ error }),
430
+ );
431
+ nextPage = undefined;
432
+ if ("error" in result) throw result.error;
433
+
434
+ const query = result.query;
435
+ // Replay all unseen messages after releasing the lock.
436
+ const messages = await replayQuery(
365
437
  cache,
366
438
  inboxUrl,
367
439
  messageIdsCacheKey,
368
- cachedMessageIds,
369
- cacheNumSeen,
370
- );
371
- for await (const m of iterator) yield m as LabeledMessage<Schema>;
372
- }
373
-
374
- if (firstResponse === undefined) {
375
- // The cursor was stale: try again
376
- const out = await this.fetchMessageBatch(
377
- inboxUrl,
378
- type,
379
- body,
380
- inboxToken,
381
- );
382
- firstResponse = out.response;
383
- waitTil = out.waitTil;
384
- }
385
-
386
- // Continue streaming results
387
- let response = firstResponse;
388
- let cursor: string;
389
- const version = cachedMessageIds?.version ?? crypto.randomUUID();
390
- let messageIds = cachedMessageIds?.messageIds ?? [];
391
- while (true) {
392
- const blob = await response.blob();
393
- const decoded = dagCborDecode(await blob.arrayBuffer());
394
- const {
395
- results,
396
- hasMore,
397
- cursor: nextCursor,
398
- } = MessageResultSchema.parse(decoded);
399
- cursor = nextCursor;
400
-
401
- const labeledMessages: LabeledMessage<Schema>[] = results.map(
402
- (result) => {
403
- const object =
404
- result[LABELED_MESSAGE_MESSAGE_KEY][MESSAGE_OBJECT_KEY];
405
- if (!validator(object)) {
406
- throw new Error("Server returned data that does not match schema");
407
- }
408
- return {
409
- ...result,
410
- [LABELED_MESSAGE_MESSAGE_KEY]: {
411
- ...result[LABELED_MESSAGE_MESSAGE_KEY],
412
- [MESSAGE_OBJECT_KEY]: object,
413
- },
414
- };
415
- },
440
+ query,
441
+ numSeen,
416
442
  );
417
-
418
- // First cache the messages with their labels
419
- await cache.messages.setMany(
420
- labeledMessages.map((m: LabeledMessageBase) => [
421
- getMessageCacheKey(inboxUrl, m[LABELED_MESSAGE_ID_KEY]),
422
- m,
423
- ]),
424
- );
425
- // Then store all the messageids
426
- messageIds = [
427
- ...messageIds,
428
- ...labeledMessages.map(
429
- (m: LabeledMessageBase) => m[LABELED_MESSAGE_ID_KEY],
430
- ),
431
- ];
432
- await cache.messageIds.set(messageIdsCacheKey, {
433
- cursor,
434
- version,
435
- messageIds,
436
- waitTil,
437
- });
438
-
439
- // Update how many we've seen
440
- cacheNumSeen += labeledMessages.length;
441
-
442
- // Fetch the next page while the caller processes the current page.
443
+ numSeen = query.messageIds.length;
444
+ seenGeneration = query.generation;
445
+ seenVersion = query.version;
446
+ firstPage = false;
447
+ // The current page's lock is released. Fetch the next page while the
448
+ // caller processes these messages; closing the stream aborts that fetch.
443
449
  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
-
459
- // Return the values
450
+ if (query.hasMore) {
451
+ // The caller may close before awaiting this request; handle rejection
452
+ // here so an aborted prefetch does not become unhandled.
453
+ nextPage = fetchPage(prefetchController.signal).then(
454
+ (query) => ({ query }),
455
+ (error) => ({ error }),
456
+ );
457
+ }
460
458
  let consumedPage = false;
461
459
  try {
462
- for (const m of labeledMessages) yield m;
460
+ for (const message of messages) {
461
+ yield message as LabeledMessage<Schema>;
462
+ }
463
463
  consumedPage = true;
464
464
  } finally {
465
465
  if (!consumedPage) prefetchController.abort();
466
466
  }
467
467
 
468
- if (!hasMore) break;
469
-
470
- const out = await nextPage!;
471
- if ("error" in out) throw out.error;
472
- response = out.value.response;
473
- waitTil = out.value.waitTil;
468
+ if (!query.hasMore) {
469
+ const outputCursor: infer_<typeof CursorSchema> = {
470
+ numSeen,
471
+ version: query.version,
472
+ messageIdsCacheKey,
473
+ objectSchema,
474
+ };
475
+ return JSON.stringify(outputCursor);
476
+ }
474
477
  }
475
-
476
- const outputCursor: infer_<typeof CursorSchema> = {
477
- numSeen: cacheNumSeen,
478
- version,
479
- messageIdsCacheKey,
480
- objectSchema,
481
- };
482
-
483
- return JSON.stringify(outputCursor);
484
478
  }
485
479
 
486
480
  query<Schema extends JSONSchema>(
@@ -490,21 +484,17 @@ export class Inboxes {
490
484
  inboxToken?: string | null,
491
485
  ): MessageStream<Schema> {
492
486
  verifyHTTPSEndpoint(inboxUrl);
493
-
494
- const body = dagCborEncode({
487
+ const body = new Uint8Array(dagCborEncode({
495
488
  tags,
496
489
  schema: objectSchema,
497
- });
498
-
499
- const messageIdsCacheKey = getMessageIdsCacheKey(inboxUrl, "query", body);
500
- return this.lockedMessageStreamer<Schema>(
501
- messageIdsCacheKey,
490
+ }));
491
+ return this.messageStreamer<Schema>({
492
+ kind: "query",
502
493
  inboxUrl,
503
- "query",
504
- new Uint8Array(body),
494
+ body,
495
+ schema: objectSchema,
505
496
  inboxToken,
506
- objectSchema,
507
- );
497
+ });
508
498
  }
509
499
 
510
500
  continueQuery(
@@ -515,83 +505,25 @@ export class Inboxes {
515
505
  verifyHTTPSEndpoint(inboxUrl);
516
506
 
517
507
  const decodedCursor = JSON.parse(cursor);
518
- const { messageIdsCacheKey, numSeen, objectSchema, version } =
519
- CursorSchema.parse(decodedCursor);
520
-
521
- return this.lockedMessageStreamer<{}>(
522
- Promise.resolve(messageIdsCacheKey),
508
+ const parsedCursor = CursorSchema.parse(decodedCursor);
509
+ return this.messageStreamer<{}>({
510
+ kind: "continue",
523
511
  inboxUrl,
524
- "query",
525
- undefined,
512
+ cursor: parsedCursor,
526
513
  inboxToken,
527
- objectSchema,
528
- version,
529
- numSeen,
530
- );
514
+ });
531
515
  }
532
516
 
533
517
  export(inboxUrl: string, inboxToken: string): MessageStream<{}> {
534
518
  verifyHTTPSEndpoint(inboxUrl);
535
- const messageIdsCacheKey = getMessageIdsCacheKey(inboxUrl, "export");
536
- return this.lockedMessageStreamer<{}>(
537
- messageIdsCacheKey,
519
+ return this.messageStreamer<{}>({
520
+ kind: "export",
538
521
  inboxUrl,
539
- "export",
540
- undefined,
541
522
  inboxToken,
542
- );
523
+ });
543
524
  }
544
525
  }
545
526
 
546
- const GraffitiObjectSchema = strictObject({
547
- value: looseObject({}),
548
- channels: array(string()),
549
- allowed: optional(nullable(array(url()))),
550
- url: url(),
551
- actor: url(),
552
- });
553
- export const Uint8ArraySchema = custom<Uint8Array>(
554
- (v): v is Uint8Array => v instanceof Uint8Array,
555
- );
556
- export const TagsSchema = array(Uint8ArraySchema);
557
-
558
- export const MESSAGE_TAGS_KEY = "t";
559
- export const MESSAGE_OBJECT_KEY = "o";
560
- export const MESSAGE_METADATA_KEY = "m";
561
- export const MessageBaseSchema = strictObject({
562
- [MESSAGE_TAGS_KEY]: TagsSchema,
563
- [MESSAGE_OBJECT_KEY]: GraffitiObjectSchema,
564
- [MESSAGE_METADATA_KEY]: Uint8ArraySchema,
565
- });
566
- type MessageBase = infer_<typeof MessageBaseSchema>;
567
-
568
- export const LABELED_MESSAGE_ID_KEY = "id";
569
- export const LABELED_MESSAGE_MESSAGE_KEY = "m";
570
- export const LABELED_MESSAGE_LABEL_KEY = "l";
571
- export const LabeledMessageBaseSchema = strictObject({
572
- [LABELED_MESSAGE_ID_KEY]: string(),
573
- [LABELED_MESSAGE_MESSAGE_KEY]: MessageBaseSchema,
574
- [LABELED_MESSAGE_LABEL_KEY]: number(),
575
- });
576
- type LabeledMessageBase = infer_<typeof LabeledMessageBaseSchema>;
577
-
578
- export type Message<Schema extends JSONSchema> = MessageBase & {
579
- [MESSAGE_OBJECT_KEY]: GraffitiObject<Schema>;
580
- };
581
- export type LabeledMessage<Schema extends JSONSchema> = LabeledMessageBase & {
582
- [LABELED_MESSAGE_MESSAGE_KEY]: {
583
- [MESSAGE_OBJECT_KEY]: GraffitiObject<Schema>;
584
- };
585
- };
586
-
587
- const SendResponseSchema = strictObject({ id: string() });
588
-
589
- const MessageResultSchema = strictObject({
590
- results: array(LabeledMessageBaseSchema),
591
- hasMore: boolean(),
592
- cursor: string(),
593
- });
594
-
595
527
  const CursorSchema = strictObject({
596
528
  messageIdsCacheKey: string(),
597
529
  version: string(),
@@ -603,153 +535,6 @@ export interface MessageStream<
603
535
  Schema extends JSONSchema,
604
536
  > extends AsyncGenerator<LabeledMessage<Schema>, string> {}
605
537
 
606
- type CacheQueryValue = {
607
- cursor: string;
608
- version: string;
609
- messageIds: string[];
610
- waitTil?: number;
611
- };
612
-
613
- async function canUseIDB(): Promise<boolean> {
614
- try {
615
- if (!globalThis.indexedDB) return false;
616
-
617
- // Small probe database
618
- await new Promise<void>((resolve, reject) => {
619
- const req = indexedDB.open("__idb_probe__", 1);
620
- req.onupgradeneeded = () => req.result.createObjectStore("k");
621
- req.onsuccess = () => {
622
- req.result.close();
623
- resolve();
624
- };
625
- req.onerror = () => reject(req.error);
626
- });
627
-
628
- return true;
629
- } catch {
630
- return false;
631
- }
632
- }
633
-
634
- type Cache = {
635
- messages: {
636
- get(k: string): Promise<LabeledMessageBase | null | undefined>;
637
- set(k: string, value: LabeledMessageBase | null): Promise<void>;
638
- setMany(entries: [string, LabeledMessageBase][]): Promise<void>;
639
- del(k: string): Promise<void>;
640
- };
641
- messageIds: {
642
- get(k: string): Promise<CacheQueryValue | undefined>;
643
- set(k: string, value: CacheQueryValue): Promise<void>;
644
- del(k: string): Promise<void>;
645
- };
646
- };
647
-
648
- function getMessageCacheKey(inboxUrl: string, messageId: string) {
649
- return `${encodeURIComponent(inboxUrl)}:${encodeURIComponent(messageId)}`;
650
- }
651
- async function getMessageIdsCacheKey(
652
- inboxUrl: string,
653
- type: "query" | "export",
654
- body?: Uint8Array,
655
- ): Promise<string> {
656
- const cacheIdData = dagCborEncode({
657
- inboxUrl,
658
- type,
659
- body: body ?? null,
660
- });
661
- return crypto.subtle
662
- .digest("SHA-256", new Uint8Array(cacheIdData))
663
- .then((bytes) =>
664
- Array.from(new Uint8Array(bytes))
665
- .map((byte) => byte.toString(16).padStart(2, "0"))
666
- .join(""),
667
- );
668
- }
669
-
670
- async function resetCacheDB() {
671
- await new Promise<void>((resolve) => {
672
- const req = indexedDB.deleteDatabase("graffiti-inbox-cache");
673
- req.onsuccess = () => resolve();
674
- req.onerror = () => resolve(); // best effort
675
- req.onblocked = () => resolve(); // best effort
676
- });
677
- }
678
-
679
- async function createCache(): Promise<Cache> {
680
- if (await canUseIDB()) {
681
- const { openDB } = await import("idb");
682
- const db = await openDB("graffiti-inbox-cache", 1, {
683
- upgrade(db) {
684
- if (!db.objectStoreNames.contains("m")) db.createObjectStore("m");
685
- if (!db.objectStoreNames.contains("q")) db.createObjectStore("q");
686
- },
687
- });
688
-
689
- return {
690
- messages: {
691
- get: async (k) => {
692
- try {
693
- return db.get("m", k);
694
- } catch (error) {
695
- console.error("Error getting message from cache:", error);
696
- console.error("resetting cache...");
697
- await resetCacheDB();
698
- return undefined;
699
- }
700
- },
701
- set: async (k, v) => {
702
- await db.put("m", v, k);
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
- },
712
- del: (k) => db.delete("m", k),
713
- },
714
- messageIds: {
715
- get: async (k) => {
716
- try {
717
- return await db.get("q", k);
718
- } catch (error) {
719
- console.error("Error getting message IDs from cache:", error);
720
- console.error("resetting cache...");
721
- await resetCacheDB();
722
- return undefined;
723
- }
724
- },
725
- set: async (k, v) => {
726
- await db.put("q", v, k);
727
- },
728
- del: (k) => db.delete("q", k),
729
- },
730
- };
731
- }
732
-
733
- const m = new Map<string, LabeledMessageBase | null>();
734
- const q = new Map<string, CacheQueryValue>();
735
-
736
- return {
737
- messages: {
738
- get: async (k) => m.get(k),
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
- },
743
- del: async (k) => void m.delete(k),
744
- },
745
- messageIds: {
746
- get: async (k) => q.get(k),
747
- set: async (k, v) => void q.set(k, v),
748
- del: async (k) => void q.delete(k),
749
- },
750
- };
751
- }
752
-
753
538
  async function waitFor(waitTil?: number) {
754
539
  if (waitTil !== undefined) {
755
540
  const waitFor = waitTil - Date.now();