@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.
- package/dist/1-services/4-inboxes/cache.d.ts +34 -0
- package/dist/1-services/4-inboxes/cache.d.ts.map +1 -0
- package/dist/1-services/4-inboxes/messages.d.ts +70 -0
- package/dist/1-services/4-inboxes/messages.d.ts.map +1 -0
- package/dist/1-services/4-inboxes.d.ts +28 -67
- package/dist/1-services/4-inboxes.d.ts.map +1 -1
- package/dist/3-protocol/2-handles.d.ts.map +1 -1
- package/dist/3-protocol/4-graffiti.d.ts +2 -2
- package/dist/3-protocol/4-graffiti.d.ts.map +1 -1
- package/dist/3-protocol/login-dialog.html.d.ts +1 -1
- package/dist/3-protocol/login-dialog.html.d.ts.map +1 -1
- package/dist/browser/index.js +7 -7
- package/dist/browser/index.js.map +4 -4
- package/dist/browser/{login-dialog.html-E6VRKMXH.js → login-dialog.html-WIDO3XZF.js} +18 -4
- package/dist/browser/{login-dialog.html-E6VRKMXH.js.map → login-dialog.html-WIDO3XZF.js.map} +2 -2
- package/dist/cjs/1-services/4-inboxes/cache.js +186 -0
- package/dist/cjs/1-services/4-inboxes/cache.js.map +7 -0
- package/dist/cjs/1-services/4-inboxes/messages.js +69 -0
- package/dist/cjs/1-services/4-inboxes/messages.js.map +7 -0
- package/dist/cjs/1-services/4-inboxes-tests.js +339 -9
- package/dist/cjs/1-services/4-inboxes-tests.js.map +3 -3
- package/dist/cjs/1-services/4-inboxes.js +210 -399
- package/dist/cjs/1-services/4-inboxes.js.map +3 -3
- package/dist/cjs/3-protocol/2-handles-tests.js +8 -0
- package/dist/cjs/3-protocol/2-handles-tests.js.map +2 -2
- package/dist/cjs/3-protocol/2-handles.js +3 -4
- package/dist/cjs/3-protocol/2-handles.js.map +2 -2
- package/dist/cjs/3-protocol/4-graffiti.js +49 -53
- package/dist/cjs/3-protocol/4-graffiti.js.map +2 -2
- package/dist/cjs/3-protocol/login-dialog.html.js +17 -3
- package/dist/cjs/3-protocol/login-dialog.html.js.map +2 -2
- package/dist/cjs/index.spec.js +90 -0
- package/dist/cjs/index.spec.js.map +3 -3
- package/dist/esm/1-services/4-inboxes/cache.js +156 -0
- package/dist/esm/1-services/4-inboxes/cache.js.map +7 -0
- package/dist/esm/1-services/4-inboxes/messages.js +60 -0
- package/dist/esm/1-services/4-inboxes/messages.js.map +7 -0
- package/dist/esm/1-services/4-inboxes-tests.js +340 -10
- package/dist/esm/1-services/4-inboxes-tests.js.map +3 -3
- package/dist/esm/1-services/4-inboxes.js +222 -383
- package/dist/esm/1-services/4-inboxes.js.map +3 -3
- package/dist/esm/3-protocol/2-handles-tests.js +8 -0
- package/dist/esm/3-protocol/2-handles-tests.js.map +2 -2
- package/dist/esm/3-protocol/2-handles.js +3 -4
- package/dist/esm/3-protocol/2-handles.js.map +2 -2
- package/dist/esm/3-protocol/4-graffiti.js +49 -53
- package/dist/esm/3-protocol/4-graffiti.js.map +2 -2
- package/dist/esm/3-protocol/login-dialog.html.js +17 -3
- package/dist/esm/3-protocol/login-dialog.html.js.map +2 -2
- package/dist/esm/index.spec.js +92 -2
- package/dist/esm/index.spec.js.map +2 -2
- package/package.json +1 -1
- package/src/1-services/4-inboxes/cache.ts +227 -0
- package/src/1-services/4-inboxes/messages.ts +63 -0
- package/src/1-services/4-inboxes-tests.ts +387 -2
- package/src/1-services/4-inboxes.ts +297 -512
- package/src/3-protocol/2-handles-tests.ts +12 -0
- package/src/3-protocol/2-handles.ts +3 -5
- package/src/3-protocol/4-graffiti.ts +69 -83
- package/src/3-protocol/login-dialog.html.ts +17 -3
- package/src/index.spec.ts +106 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { JSONSchema
|
|
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
|
|
173
|
-
cache: Cache,
|
|
174
|
-
inboxUrl: string,
|
|
230
|
+
protected async withQueryLock<T>(
|
|
175
231
|
messageIdsCacheKey: string,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
let
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
247
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
229
248
|
},
|
|
230
|
-
async (
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
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)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
500
|
-
return this.lockedMessageStreamer<Schema>(
|
|
501
|
-
messageIdsCacheKey,
|
|
490
|
+
}));
|
|
491
|
+
return this.messageStreamer<Schema>({
|
|
492
|
+
kind: "query",
|
|
502
493
|
inboxUrl,
|
|
503
|
-
|
|
504
|
-
|
|
494
|
+
body,
|
|
495
|
+
schema: objectSchema,
|
|
505
496
|
inboxToken,
|
|
506
|
-
|
|
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
|
|
519
|
-
|
|
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
|
-
|
|
525
|
-
undefined,
|
|
512
|
+
cursor: parsedCursor,
|
|
526
513
|
inboxToken,
|
|
527
|
-
|
|
528
|
-
version,
|
|
529
|
-
numSeen,
|
|
530
|
-
);
|
|
514
|
+
});
|
|
531
515
|
}
|
|
532
516
|
|
|
533
517
|
export(inboxUrl: string, inboxToken: string): MessageStream<{}> {
|
|
534
518
|
verifyHTTPSEndpoint(inboxUrl);
|
|
535
|
-
|
|
536
|
-
|
|
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();
|