Fix loading events to avoid lost provenance

This commit is contained in:
Jon Staab 2026-08-18 09:20:30 -07:00
parent 20f12a48a4
commit 45cbd1d6ba
2 changed files with 67 additions and 48 deletions

View file

@ -3,7 +3,7 @@ import type {Unsubscriber} from "svelte/store"
import {deleteDB} from "idb"
import {SecureStorage} from "@aparajita/capacitor-secure-storage"
import {Preferences} from "@capacitor/preferences"
import {noop, on, throttle, batch, call, makeQueue} from "@welshman/lib"
import {noop, on, throttle, batch, call, addToMapKey, makeQueue} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {
ALERT_ANDROID,
@ -146,11 +146,13 @@ const kinds = {
],
}
// Space and room events mean nothing apart from the relay they came from — welshman keys them
// per relay, and only counts room state the relay itself signed.
const isRelayScoped = (event: TrustedEvent) =>
kinds.space.includes(event.kind) || kinds.room.includes(event.kind)
const shouldPersistEvent = (event: TrustedEvent) =>
kinds.meta.includes(event.kind) ||
kinds.alert.includes(event.kind) ||
kinds.space.includes(event.kind) ||
kinds.room.includes(event.kind)
kinds.meta.includes(event.kind) || kinds.alert.includes(event.kind) || isRelayScoped(event)
type TrackerItem = {id: string; relays: string[]}
@ -191,9 +193,7 @@ class Storage {
private start = async () => {
await this.db.connect()
const [, unsubscribeRelays] = await Promise.all([this.loadCriticalEvents(), this.initRelays()])
await this.loadCriticalTracker()
const [, unsubscribeRelays] = await Promise.all([this.loadCriticalData(), this.initRelays()])
this.addUnsubscriber(this.syncEvents())
this.addUnsubscriber(this.syncTracker())
@ -224,25 +224,57 @@ class Storage {
}
}
private loadCriticalEvents = async () => {
const table = this.db.table<TrustedEvent>("events")
const initialEvents = await table.getAll()
const keep: TrustedEvent[] = []
const drop: string[] = []
/**
* Events load together with the relays they came from, since a relay-scoped event that lost
* its provenance can never be keyed to a space again — it sits in the repository invisible,
* and negentropy reconciles it away as already-present, so it never comes back. Drop those
* and let the next sync pull them down again.
*/
private loadCriticalData = async () => {
const eventsTable = this.db.table<TrustedEvent>("events")
const trackerTable = this.db.table<TrackerItem>("tracker")
const [storedEvents, storedItems] = await Promise.all([
eventsTable.getAll(),
trackerTable.getAll(),
])
for (const event of initialEvents) {
if (shouldPersistEvent(event)) {
const relaysById = new Map(storedItems.map(({id, relays}) => [id, new Set(relays)]))
const events: TrustedEvent[] = []
const staleEvents: string[] = []
for (const event of storedEvents) {
if (shouldPersistEvent(event) && (!isRelayScoped(event) || relaysById.has(event.id))) {
event[verifiedSymbol] = true
keep.push(event)
events.push(event)
} else {
drop.push(event.id)
staleEvents.push(event.id)
}
}
this.app.repository.load(keep)
for (const [id, relays] of this.app.tracker.relaysById) {
for (const relay of relays) {
addToMapKey(relaysById, id, relay)
}
}
if (drop.length > 0) {
void table.bulkDelete(drop)
this.app.repository.load([...this.app.repository.dump(), ...events])
const staleItems = storedItems
.map(item => item.id)
.filter(id => !this.app.repository.getEvent(id))
for (const id of staleItems) {
relaysById.delete(id)
}
this.app.tracker.load(relaysById)
if (staleEvents.length > 0) {
void eventsTable.bulkDelete(staleEvents)
}
if (staleItems.length > 0) {
void trackerTable.bulkDelete(staleItems)
}
}
@ -296,26 +328,6 @@ class Storage {
)
}
private loadCriticalTracker = async () => {
const table = this.db.table<TrackerItem>("tracker")
const relaysById = new Map<string, Set<string>>()
const stale: string[] = []
for (const {id, relays} of await table.getAll()) {
if (this.app.repository.getEvent(id)) {
relaysById.set(id, new Set(relays))
} else {
stale.push(id)
}
}
this.app.tracker.load(relaysById)
if (stale.length > 0) {
void table.bulkDelete(stale)
}
}
private syncTracker = () => {
const table = this.db.table<TrackerItem>("tracker")

View file

@ -14,14 +14,13 @@
import {context as pomadeContext} from "@pomade/core"
import {sync, throttled} from "@welshman/store"
import {setNip55Plugin} from "@welshman/signer"
import * as domain from "@welshman/domain"
import * as util from "@welshman/util"
import * as lib from "@welshman/lib"
import {Logger} from "@welshman/app"
import {isMobile, documentActive} from "@lib/html"
import AppContainer from "@app/components/AppContainer.svelte"
import ModalContainer from "@app/components/ModalContainer.svelte"
import {app} from "@app/core"
import * as core from "@app/core"
import {setupHistory} from "@app/routes"
import {setupAnalytics} from "@app/analytics"
import {setupLogging} from "@app/logger"
@ -65,7 +64,7 @@
)
// Add stuff to window for convenience
Object.assign(window, {get, nip19, theme, app, domain, Logger, ...lib, ...util})
Object.assign(window, {get, nip19, theme, Logger, ...lib, ...util, ...core})
// Set up context for various modules
pomadeContext.setSignerUrls(env.POMADE_SIGNERS)
@ -214,14 +213,21 @@
unsubscribers.push(Push.sync())
// Logging in swaps in a fresh app — its policies rebind themselves, we just have to sync
// application data against the new identity's relays.
let currentApp = app.get()
// application data against the new identity's relays. Loading the new app's storage
// replaces its repository and tracker wholesale, so wait for it the way startup does.
let currentApp = core.app.get()
const resync = async () => {
await storage.get()?.ready
syncApplicationData()
}
unsubscribers.push(
app.subscribe($app => {
core.app.subscribe($app => {
if ($app !== currentApp) {
currentApp = $app
syncApplicationData()
resync()
}
}),
)
@ -282,7 +288,8 @@
})
$effect(() => {
const title = getPageTitle({page: $page, pubkey: $app.user?.pubkey})
const {user} = core.app.get()
const title = getPageTitle({page: $page, pubkey: user?.pubkey})
// While the tab isn't actively focused the user isn't actually looking at the
// active page, so count notifications for it too rather than treating it as read.
const paths = $documentActive ? $notificationPaths : $allNotifications