@estiva-app/platform 0.3.1 → 0.5.0

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.
@@ -0,0 +1,583 @@
1
+ /**
2
+ * A copy of what the relay told one person, kept on the device (PEE-2).
3
+ *
4
+ * The copy holds **signed events**, the stores' raw input, never a folded view:
5
+ * a fold can change shape in any release, an event cannot. So an app draws
6
+ * from the copy by running the reads it would have sent, against the copy, and
7
+ * folding the answer exactly as it folds the relay's — and then sends the same
8
+ * reads to the relay and folds again. Nothing here knows which app or store it
9
+ * feeds.
10
+ *
11
+ * ## What the copy may answer
12
+ *
13
+ * A read whose every filter has been answered by the relay before, on this
14
+ * device, for this person. Its key leaves out `since` and `until`, which are
15
+ * usually a clock: "the last 30 days" asked yesterday is the same question.
16
+ * A filter never asked, or a `search`, is not answered — an empty answer from
17
+ * a copy that simply never saw the question would draw "nothing here", which
18
+ * is a claim, where no answer draws today's placeholder.
19
+ *
20
+ * ## Keeping it honest without a sequence number
21
+ *
22
+ * The relay has no change feed to replay, so every answer it gives is also a
23
+ * correction: an event the copy holds that matches a filter the relay just
24
+ * answered, and is not in that answer, is gone (deleted, or in a Folder this
25
+ * person has lost — Buzz answers lost access with an empty list). When the
26
+ * answer was cut by its `limit`, only the window it covers is judged: below
27
+ * its oldest event the relay said nothing. A `kind:5` drops the events it
28
+ * names that its own author signed. A replaceable event keeps only its newest.
29
+ *
30
+ * ## Plaintexts
31
+ *
32
+ * An app's own blob is ciphertext on the wire; decrypting it is a round trip
33
+ * the relay's signer meters. The plaintext is the person's own data, so it is
34
+ * kept beside the event that carried it and handed back for the same
35
+ * ciphertext — which always decrypts to the same text, so it is never stale.
36
+ */
37
+ import type { SignedEvent } from '@estiva-app/protocol'
38
+
39
+ /** A Nostr filter as an app sends it: `ids`, `kinds`, `authors`, `#x`, `since`, `until`, `limit`. */
40
+ export type SnapshotFilter = Record<string, unknown>
41
+
42
+ /** One event in storage, with the plaintext of its content when the app kept one. */
43
+ export interface StoredSnapshotEvent {
44
+ event: SignedEvent
45
+ plaintext?: string
46
+ /** Epoch ms of the last answer that carried it: the oldest go first past the cap. */
47
+ seenAt: number
48
+ }
49
+
50
+ /** What a scope holds in storage. */
51
+ export interface SnapshotRecord {
52
+ version: number
53
+ events: StoredSnapshotEvent[]
54
+ /** The keys of every filter the relay has answered (see {@link snapshotFilterKey}). */
55
+ filters: string[]
56
+ }
57
+
58
+ /** What changed since the last save. */
59
+ export interface SnapshotChange {
60
+ version: number
61
+ /** The storage's sign-out count when the copy was read: a save from before a sign-out is refused. */
62
+ epoch: number
63
+ put: StoredSnapshotEvent[]
64
+ remove: string[]
65
+ filters: string[]
66
+ }
67
+
68
+ /** Where a copy lives between page loads. {@link indexedDbSnapshotStorage} is the browser's. */
69
+ export interface SnapshotStorage {
70
+ /** A scope's record, or null; and the storage's sign-out count, which every save names. */
71
+ load(scope: string): Promise<{ epoch: number; record: SnapshotRecord | null }>
72
+ /** Writes nothing when `change.epoch` is not the current count: somebody signed out since. */
73
+ save(scope: string, change: SnapshotChange): Promise<void>
74
+ /**
75
+ * Remove every scope and count a sign-out, so a copy still open — in this
76
+ * tab or another — can never write the person's data back afterwards.
77
+ */
78
+ clear(): Promise<void>
79
+ }
80
+
81
+ export interface SnapshotTimers {
82
+ setTimeout(run: () => void, ms: number): unknown
83
+ clearTimeout(handle: unknown): void
84
+ }
85
+
86
+ const host = globalThis as unknown as SnapshotTimers
87
+
88
+ export interface EventSnapshotOptions {
89
+ storage: SnapshotStorage
90
+ /** Whose blobs' plaintexts are kept; defaults to the pubkey in `scope`. */
91
+ owner?: string
92
+ /** One person on one relay: {@link snapshotScope}. */
93
+ scope: string
94
+ /** The app's schema version. A copy written under another one is dropped unread. */
95
+ version: number
96
+ /** Past this many events the least recently seen are dropped. */
97
+ maxEvents?: number
98
+ /** How long after a change the copy is written. */
99
+ saveAfterMs?: number
100
+ now?: () => number
101
+ timers?: SnapshotTimers
102
+ }
103
+
104
+ export interface EventSnapshot {
105
+ /** Settles once the stored copy has been read, or found unusable. Never rejects. */
106
+ ready: Promise<void>
107
+ /** The events the copy holds for these filters, as the relay would order them; `null` when it cannot answer. */
108
+ answer(filters: SnapshotFilter[]): SignedEvent[] | null
109
+ /** Take in the relay's answer to these filters: add what it sent, drop what it no longer has. */
110
+ absorb(filters: SnapshotFilter[], events: SignedEvent[]): void
111
+ /** The kept plaintext of a ciphertext this copy holds, if any. */
112
+ plaintextOf(content: string): string | undefined
113
+ keepPlaintext(content: string, plaintext: string): void
114
+ size(): number
115
+ /** Write what changed now, rather than after `saveAfterMs`. */
116
+ flush(): Promise<void>
117
+ /** Stop, writing what changed first; nothing more is written after. */
118
+ close(): void
119
+ /** Stop and write nothing: the copy is about to be cleared (sign-out). */
120
+ discard(): void
121
+ }
122
+
123
+ export const SNAPSHOT_MAX_EVENTS = 10_000
124
+ export const SNAPSHOT_SAVE_AFTER_MS = 1_000
125
+
126
+ /** The scope of one person's copy on one relay. */
127
+ export function snapshotScope(relayUrl: string, pubkey: string): string {
128
+ return `${relayUrl.replace(/\/+$/, '')}|${pubkey}`
129
+ }
130
+
131
+ function sortedValue(value: unknown): unknown {
132
+ return Array.isArray(value) ? [...value].map(String).sort() : value
133
+ }
134
+
135
+ /** A filter's identity as a question, without its clock (`since`, `until`). */
136
+ export function snapshotFilterKey(filter: SnapshotFilter): string {
137
+ const keys = Object.keys(filter)
138
+ .filter((k) => k !== 'since' && k !== 'until')
139
+ .sort()
140
+ return JSON.stringify(keys.map((k) => [k, sortedValue(filter[k])]))
141
+ }
142
+
143
+ /** Whether an event matches a filter, `limit` aside — NIP-01's rule. */
144
+ export function matchesSnapshotFilter(filter: SnapshotFilter, event: SignedEvent): boolean {
145
+ for (const [key, value] of Object.entries(filter)) {
146
+ if (key === 'limit') continue
147
+ if (key === 'ids') {
148
+ if (!(value as string[]).includes(event.id)) return false
149
+ } else if (key === 'kinds') {
150
+ if (!(value as number[]).includes(event.kind)) return false
151
+ } else if (key === 'authors') {
152
+ if (!(value as string[]).includes(event.pubkey)) return false
153
+ } else if (key === 'since') {
154
+ if (event.created_at < (value as number)) return false
155
+ } else if (key === 'until') {
156
+ if (event.created_at > (value as number)) return false
157
+ } else if (key.startsWith('#') && key.length === 2) {
158
+ const name = key.slice(1)
159
+ const wanted = value as string[]
160
+ if (!event.tags.some((tag) => tag[0] === name && tag[1] !== undefined && wanted.includes(tag[1]))) return false
161
+ } else {
162
+ // `search`, or anything this rule does not know: not something a copy can judge.
163
+ return false
164
+ }
165
+ }
166
+ return true
167
+ }
168
+
169
+ function answerable(filter: SnapshotFilter): boolean {
170
+ return Object.keys(filter).every(
171
+ (key) => ['ids', 'kinds', 'authors', 'since', 'until', 'limit'].includes(key) || (key.startsWith('#') && key.length === 2),
172
+ )
173
+ }
174
+
175
+ /** Newest first, then by id: the order a relay answers in. */
176
+ function byRelayOrder(a: SignedEvent, b: SignedEvent): number {
177
+ return b.created_at - a.created_at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
178
+ }
179
+
180
+ /** The coordinate a replaceable event replaces, or null for a regular one. */
181
+ function replaceableKey(event: SignedEvent): string | null {
182
+ const { kind } = event
183
+ if (kind === 0 || kind === 3 || (kind >= 10_000 && kind < 20_000)) return `${kind}:${event.pubkey}`
184
+ if (kind >= 30_000 && kind < 40_000) {
185
+ const d = event.tags.find((tag) => tag[0] === 'd')?.[1] ?? ''
186
+ return `${kind}:${event.pubkey}:${d}`
187
+ }
188
+ return null
189
+ }
190
+
191
+ export function createEventSnapshot(options: EventSnapshotOptions): EventSnapshot {
192
+ const { storage, scope, version } = options
193
+ const owner = options.owner ?? scope.slice(scope.lastIndexOf('|') + 1)
194
+ /** The storage's sign-out count when this copy was read; no read, no write. */
195
+ let epoch: number | undefined
196
+ const maxEvents = options.maxEvents ?? SNAPSHOT_MAX_EVENTS
197
+ const saveAfterMs = options.saveAfterMs ?? SNAPSHOT_SAVE_AFTER_MS
198
+ const now = options.now ?? (() => Date.now())
199
+ const timers = () => options.timers ?? host
200
+
201
+ const events = new Map<string, StoredSnapshotEvent>()
202
+ const byCoordinate = new Map<string, string>()
203
+ const byContent = new Map<string, string>()
204
+ /** Plaintexts for a ciphertext no held event carries: this page load only. */
205
+ const looseText = new Map<string, string>()
206
+ const filters = new Set<string>()
207
+ const dirty = new Set<string>()
208
+ const removed = new Set<string>()
209
+ let filtersDirty = false
210
+ let timer: unknown
211
+ let closed = false
212
+
213
+ function drop(id: string) {
214
+ const held = events.get(id)
215
+ if (!held) return
216
+ events.delete(id)
217
+ const coordinate = replaceableKey(held.event)
218
+ if (coordinate) {
219
+ if (byCoordinate.get(coordinate) === id) byCoordinate.delete(coordinate)
220
+ if (byContent.get(held.event.content) === id) byContent.delete(held.event.content)
221
+ }
222
+ dirty.delete(id)
223
+ removed.add(id)
224
+ }
225
+
226
+ function hold(stored: StoredSnapshotEvent, mark: boolean): void {
227
+ const { event } = stored
228
+ const coordinate = replaceableKey(event)
229
+ if (coordinate) {
230
+ const current = byCoordinate.get(coordinate)
231
+ const currentEvent = current ? events.get(current)?.event : undefined
232
+ if (currentEvent && currentEvent.id !== event.id) {
233
+ if (byRelayOrder(currentEvent, event) <= 0) return
234
+ drop(currentEvent.id)
235
+ }
236
+ byCoordinate.set(coordinate, event.id)
237
+ }
238
+ const previous = events.get(event.id)
239
+ // A plaintext belongs to the person's own blob — a replaceable event they signed — and
240
+ // never to somebody else's event that happens to carry the same ciphertext.
241
+ const own = coordinate !== null && event.pubkey === owner
242
+ const plaintext = own ? (stored.plaintext ?? previous?.plaintext ?? looseText.get(event.content)) : undefined
243
+ events.set(event.id, { event, seenAt: stored.seenAt, ...(plaintext !== undefined ? { plaintext } : {}) })
244
+ if (own && event.content) byContent.set(event.content, event.id)
245
+ if (mark) {
246
+ dirty.add(event.id)
247
+ removed.delete(event.id)
248
+ }
249
+ }
250
+
251
+ function schedule() {
252
+ if (closed || timer !== undefined) return
253
+ timer = timers().setTimeout(() => {
254
+ timer = undefined
255
+ void flush()
256
+ }, saveAfterMs)
257
+ }
258
+
259
+ async function flush(): Promise<void> {
260
+ if (timer !== undefined) {
261
+ timers().clearTimeout(timer)
262
+ timer = undefined
263
+ }
264
+ if (closed || epoch === undefined || (!dirty.size && !removed.size && !filtersDirty)) return
265
+ const change: SnapshotChange = {
266
+ version,
267
+ epoch,
268
+ put: [...dirty].flatMap((id) => {
269
+ const held = events.get(id)
270
+ return held ? [held] : []
271
+ }),
272
+ remove: [...removed],
273
+ filters: [...filters],
274
+ }
275
+ dirty.clear()
276
+ removed.clear()
277
+ filtersDirty = false
278
+ try {
279
+ await storage.save(scope, change)
280
+ } catch {
281
+ // A copy that could not be written is a slower next load, not a failure —
282
+ // but what it carried is asked again next time, removals above all: a
283
+ // deletion that never reached storage would be drawn on every reload.
284
+ for (const stored of change.put) if (events.has(stored.event.id) && !removed.has(stored.event.id)) dirty.add(stored.event.id)
285
+ for (const id of change.remove) if (!events.has(id)) removed.add(id)
286
+ filtersDirty = true
287
+ schedule()
288
+ }
289
+ }
290
+
291
+ /** Answers taken in before the stored copy was read: applied over it, never under it. */
292
+ const early: [SnapshotFilter[], SignedEvent[]][] = []
293
+ let loaded = false
294
+
295
+ const ready = (async () => {
296
+ try {
297
+ const read = await storage.load(scope)
298
+ epoch = read.epoch
299
+ const record = read.record
300
+ if (record && record.version === version) {
301
+ for (const stored of record.events) hold(stored, false)
302
+ for (const key of record.filters) filters.add(key)
303
+ }
304
+ } catch {
305
+ // Unreadable (a private window, storage refused, a corrupt record): no copy.
306
+ }
307
+ loaded = true
308
+ for (const [asked, answer] of early.splice(0)) take(asked, answer)
309
+ })()
310
+
311
+ function take(asked: SnapshotFilter[], answer: SignedEvent[]) {
312
+ if (closed) return
313
+ const seenAt = now()
314
+ for (const event of answer) hold({ event, seenAt }, true)
315
+ const returned = new Set(answer.map((event) => event.id))
316
+ for (const filter of asked) {
317
+ if (!answerable(filter)) continue
318
+ const key = snapshotFilterKey(filter)
319
+ if (!filters.has(key)) {
320
+ filters.add(key)
321
+ filtersDirty = true
322
+ }
323
+ const sent = answer.filter((event) => matchesSnapshotFilter(filter, event))
324
+ // Cut by its limit — counted on the whole answer too when it is the only filter, so an
325
+ // event the relay matched and this rule does not cannot make a cut answer look whole.
326
+ const limit = typeof filter.limit === 'number' ? filter.limit : Infinity
327
+ const cut = sent.length >= limit || (asked.length === 1 && answer.length >= limit)
328
+ const floor = cut && sent.length ? Math.min(...sent.map((event) => event.created_at)) : cut ? Infinity : -Infinity
329
+ for (const stored of [...events.values()]) {
330
+ const { event } = stored
331
+ // At the cut's own second the relay may have held back events it still has: not judged.
332
+ if (returned.has(event.id) || (cut && event.created_at <= floor)) continue
333
+ if (matchesSnapshotFilter(filter, event)) drop(event.id)
334
+ }
335
+ }
336
+ for (const event of answer) {
337
+ if (event.kind !== 5) continue
338
+ for (const tag of event.tags) {
339
+ if (!tag[1]) continue
340
+ if (tag[0] === 'e' && events.get(tag[1])?.event.pubkey === event.pubkey) drop(tag[1])
341
+ // An addressable file deleted by its coordinate: what its author wrote up to the deletion.
342
+ if (tag[0] === 'a' && tag[1].split(':')[1] === event.pubkey) {
343
+ const id = byCoordinate.get(tag[1])
344
+ const target = id ? events.get(id)?.event : undefined
345
+ if (target && target.created_at <= event.created_at) drop(target.id)
346
+ }
347
+ }
348
+ }
349
+ trim()
350
+ schedule()
351
+ }
352
+
353
+ function trim() {
354
+ if (events.size <= maxEvents) return
355
+ const oldest = [...events.values()].sort((a, b) => a.seenAt - b.seenAt)
356
+ for (const stored of oldest.slice(0, events.size - maxEvents)) drop(stored.event.id)
357
+ }
358
+
359
+ return {
360
+ ready,
361
+ answer(asked) {
362
+ if (!asked.length) return null
363
+ if (!asked.every((f) => answerable(f) && filters.has(snapshotFilterKey(f)))) return null
364
+ const out = new Map<string, SignedEvent>()
365
+ for (const filter of asked) {
366
+ const matched = [...events.values()]
367
+ .map((stored) => stored.event)
368
+ .filter((event) => matchesSnapshotFilter(filter, event))
369
+ .sort(byRelayOrder)
370
+ const limit = typeof filter.limit === 'number' ? filter.limit : matched.length
371
+ for (const event of matched.slice(0, limit)) out.set(event.id, event)
372
+ }
373
+ return [...out.values()].sort(byRelayOrder)
374
+ },
375
+ absorb(asked, answer) {
376
+ if (closed) return
377
+ if (!loaded) early.push([asked, answer])
378
+ else take(asked, answer)
379
+ },
380
+ plaintextOf(content) {
381
+ const id = byContent.get(content)
382
+ return (id ? events.get(id)?.plaintext : undefined) ?? looseText.get(content)
383
+ },
384
+ keepPlaintext(content, plaintext) {
385
+ if (closed) return
386
+ const id = byContent.get(content)
387
+ const held = id ? events.get(id) : undefined
388
+ if (!held) {
389
+ looseText.set(content, plaintext)
390
+ return
391
+ }
392
+ if (held.plaintext === plaintext) return
393
+ events.set(held.event.id, { ...held, plaintext })
394
+ dirty.add(held.event.id)
395
+ schedule()
396
+ },
397
+ size: () => events.size,
398
+ flush,
399
+ close() {
400
+ // What changed is written first: a deletion taken in a moment ago must not come back next load.
401
+ void flush()
402
+ closed = true
403
+ },
404
+ discard() {
405
+ if (timer !== undefined) timers().clearTimeout(timer)
406
+ timer = undefined
407
+ closed = true
408
+ },
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Keeps nothing between page loads: for a test, and for a browser without
414
+ * IndexedDB, where the copy still answers within one load.
415
+ */
416
+ export function memorySnapshotStorage(): SnapshotStorage & { records: Map<string, SnapshotRecord> } {
417
+ const records = new Map<string, SnapshotRecord>()
418
+ let epoch = 0
419
+ return {
420
+ records,
421
+ async load(scope) {
422
+ const record = records.get(scope)
423
+ return { epoch, record: record ? { ...record, events: [...record.events], filters: [...record.filters] } : null }
424
+ },
425
+ async save(scope, change) {
426
+ if (change.epoch !== epoch) return
427
+ const record = records.get(scope)
428
+ const held = new Map(record && record.version === change.version ? record.events.map((s) => [s.event.id, s]) : [])
429
+ for (const id of change.remove) held.delete(id)
430
+ for (const stored of change.put) held.set(stored.event.id, stored)
431
+ records.set(scope, { version: change.version, events: [...held.values()], filters: [...change.filters] })
432
+ },
433
+ async clear() {
434
+ records.clear()
435
+ epoch += 1
436
+ },
437
+ }
438
+ }
439
+
440
+ /*
441
+ IndexedDB, typed by what this file touches. The package compiles without
442
+ `lib: dom` (tsconfig.base.json), so the factory is a parameter shaped like the
443
+ part of `window.indexedDB` used here, never the global.
444
+ */
445
+ interface IdbRequest<T = unknown> {
446
+ result: T
447
+ error: unknown
448
+ onsuccess: ((this: unknown, ev: unknown) => unknown) | null
449
+ onerror: ((this: unknown, ev: unknown) => unknown) | null
450
+ }
451
+ interface IdbOpenRequest extends IdbRequest<IdbDatabase> {
452
+ onupgradeneeded: ((this: unknown, ev: unknown) => unknown) | null
453
+ }
454
+ interface IdbIndex {
455
+ getAll(query: unknown): IdbRequest<unknown[]>
456
+ getAllKeys(query: unknown): IdbRequest<unknown[]>
457
+ }
458
+ interface IdbObjectStore {
459
+ put(value: unknown): IdbRequest
460
+ get(key: unknown): IdbRequest<unknown>
461
+ delete(key: unknown): IdbRequest
462
+ clear(): IdbRequest
463
+ index(name: string): IdbIndex
464
+ createIndex(name: string, keyPath: string): unknown
465
+ }
466
+ interface IdbTransaction {
467
+ objectStore(name: string): IdbObjectStore
468
+ oncomplete: ((this: unknown, ev: unknown) => unknown) | null
469
+ onerror: ((this: unknown, ev: unknown) => unknown) | null
470
+ onabort: ((this: unknown, ev: unknown) => unknown) | null
471
+ error: unknown
472
+ }
473
+ interface IdbDatabase {
474
+ objectStoreNames: { contains(name: string): boolean }
475
+ createObjectStore(name: string, options: { keyPath: string | string[] }): IdbObjectStore
476
+ transaction(stores: string[], mode: 'readonly' | 'readwrite'): IdbTransaction
477
+ close(): void
478
+ }
479
+ /** The part of `window.indexedDB` a snapshot uses. */
480
+ export interface IdbFactoryLike {
481
+ open(name: string, version: number): IdbOpenRequest
482
+ }
483
+
484
+ const EVENTS = 'events'
485
+ const META = 'meta'
486
+ /** The `meta` row counting sign-outs: no scope can be named this (`snapshotScope` has a `|`). */
487
+ const SIGN_OUTS = '\u0000sign-outs'
488
+
489
+ function request<T>(req: IdbRequest<T>): Promise<T> {
490
+ return new Promise((resolve, reject) => {
491
+ req.onsuccess = () => resolve(req.result)
492
+ req.onerror = () => reject(req.error)
493
+ })
494
+ }
495
+
496
+ function done(tx: IdbTransaction): Promise<void> {
497
+ return new Promise((resolve, reject) => {
498
+ tx.oncomplete = () => resolve()
499
+ tx.onerror = () => reject(tx.error)
500
+ tx.onabort = () => reject(tx.error)
501
+ })
502
+ }
503
+
504
+ /**
505
+ * The browser's storage for a copy: one database, `events` keyed by scope and
506
+ * id, and a `meta` row per scope with its version and answered filters.
507
+ */
508
+ export function indexedDbSnapshotStorage(indexedDB: IdbFactoryLike, name = 'estiva-snapshot'): SnapshotStorage {
509
+ let opened: Promise<IdbDatabase> | undefined
510
+ const db = () => {
511
+ opened ??= new Promise<IdbDatabase>((resolve, reject) => {
512
+ const req = indexedDB.open(name, 1)
513
+ req.onupgradeneeded = () => {
514
+ const database = req.result
515
+ if (!database.objectStoreNames.contains(EVENTS)) {
516
+ database.createObjectStore(EVENTS, { keyPath: ['scope', 'id'] }).createIndex('scope', 'scope')
517
+ }
518
+ if (!database.objectStoreNames.contains(META)) database.createObjectStore(META, { keyPath: 'scope' })
519
+ }
520
+ req.onsuccess = () => resolve(req.result)
521
+ req.onerror = () => reject(req.error)
522
+ })
523
+ // A failed open is asked again next time rather than held for the page.
524
+ opened.catch(() => (opened = undefined))
525
+ return opened
526
+ }
527
+
528
+ const epochOf = async (meta: IdbObjectStore) =>
529
+ ((await request(meta.get(SIGN_OUTS))) as { epoch: number } | undefined)?.epoch ?? 0
530
+
531
+ return {
532
+ async load(scope) {
533
+ const database = await db()
534
+ const tx = database.transaction([EVENTS, META], 'readonly')
535
+ const [epoch, meta, rows] = await Promise.all([
536
+ epochOf(tx.objectStore(META)),
537
+ request(tx.objectStore(META).get(scope)) as Promise<{ version: number; filters: string[] } | undefined>,
538
+ request(tx.objectStore(EVENTS).index('scope').getAll(scope)) as Promise<(StoredSnapshotEvent & { scope: string; id: string })[]>,
539
+ ])
540
+ if (!meta) return { epoch, record: null }
541
+ return {
542
+ epoch,
543
+ record: {
544
+ version: meta.version,
545
+ filters: meta.filters,
546
+ events: rows.map(({ event, plaintext, seenAt }) => ({ event, seenAt, ...(plaintext !== undefined ? { plaintext } : {}) })),
547
+ },
548
+ }
549
+ },
550
+ async save(scope, change) {
551
+ const database = await db()
552
+ // One transaction from the check to the last write: a sign-out in this tab or another
553
+ // is either wholly before it (and refuses it) or wholly after it (and clears it).
554
+ const tx = database.transaction([EVENTS, META], 'readwrite')
555
+ const metaStore = tx.objectStore(META)
556
+ const events = tx.objectStore(EVENTS)
557
+ const [epoch, meta] = await Promise.all([
558
+ epochOf(metaStore),
559
+ request(metaStore.get(scope)) as Promise<{ version: number } | undefined>,
560
+ ])
561
+ if (epoch === change.epoch) {
562
+ if (meta && meta.version !== change.version) {
563
+ const keys = await request(events.index('scope').getAllKeys(scope))
564
+ for (const key of keys) events.delete(key)
565
+ }
566
+ for (const id of change.remove) events.delete([scope, id])
567
+ for (const stored of change.put) events.put({ scope, id: stored.event.id, ...stored })
568
+ metaStore.put({ scope, version: change.version, filters: change.filters })
569
+ }
570
+ await done(tx)
571
+ },
572
+ async clear() {
573
+ const database = await db()
574
+ const tx = database.transaction([EVENTS, META], 'readwrite')
575
+ const epoch = await epochOf(tx.objectStore(META))
576
+ tx.objectStore(EVENTS).clear()
577
+ tx.objectStore(META).clear()
578
+ // Counted, so every copy opened before now — in any tab — writes nothing again.
579
+ tx.objectStore(META).put({ scope: SIGN_OUTS, epoch: epoch + 1 })
580
+ await done(tx)
581
+ },
582
+ }
583
+ }
package/src/index.ts CHANGED
@@ -35,6 +35,16 @@ export {
35
35
  type FolderListener,
36
36
  } from './folder-activity.js'
37
37
  export { assumeOnline, browserOnlineSource, type OnlineSource, type OnlineTarget } from './online.js'
38
+ export {
39
+ RETRY_FIRST_MS,
40
+ RETRY_MAX_MS,
41
+ createRetryingRead,
42
+ retryDelayMs,
43
+ type RetryTimers,
44
+ type RetryingRead,
45
+ type RetryingReadOptions,
46
+ type RetryingReadSnapshot,
47
+ } from './retrying-read.js'
38
48
  export {
39
49
  DEFAULT_BACKOFF_MS,
40
50
  DEFAULT_MERGE_MS,
@@ -53,3 +63,22 @@ export {
53
63
  type SchedulerClock,
54
64
  type VisibilityTarget,
55
65
  } from './refresh.js'
66
+ export {
67
+ SNAPSHOT_MAX_EVENTS,
68
+ SNAPSHOT_SAVE_AFTER_MS,
69
+ createEventSnapshot,
70
+ indexedDbSnapshotStorage,
71
+ matchesSnapshotFilter,
72
+ memorySnapshotStorage,
73
+ snapshotFilterKey,
74
+ snapshotScope,
75
+ type EventSnapshot,
76
+ type EventSnapshotOptions,
77
+ type IdbFactoryLike,
78
+ type SnapshotChange,
79
+ type SnapshotFilter,
80
+ type SnapshotRecord,
81
+ type SnapshotStorage,
82
+ type SnapshotTimers,
83
+ type StoredSnapshotEvent,
84
+ } from './event-snapshot.js'