@estiva-app/platform 0.4.0 → 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.
- package/CHANGELOG.md +35 -0
- package/README.md +38 -1
- package/dist/event-snapshot.d.ts +179 -0
- package/dist/event-snapshot.d.ts.map +1 -0
- package/dist/event-snapshot.js +449 -0
- package/dist/event-snapshot.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/event-snapshot.ts +583 -0
- package/src/index.ts +19 -0
|
@@ -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
|
@@ -63,3 +63,22 @@ export {
|
|
|
63
63
|
type SchedulerClock,
|
|
64
64
|
type VisibilityTarget,
|
|
65
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'
|