diff --git a/packages/admin/app/composables/useAttendees.ts b/packages/admin/app/composables/useAttendees.ts index f9151dd..7fbefa5 100644 --- a/packages/admin/app/composables/useAttendees.ts +++ b/packages/admin/app/composables/useAttendees.ts @@ -2,6 +2,7 @@ import { ref, computed } from 'vue' import { h160ToSs58, walletAddressToH160 } from '@festival/shared/utils/address' import { useWalletStore } from '@festival/shared/host/wallet' import { festivalState } from '@festival/shared/cache/festival-state' +import { pendingCheckins } from '@festival/shared/cache/pending' import { bootLoadAdmin } from './useBootLoad' export interface CheckedInAttendee { @@ -17,14 +18,34 @@ export interface CheckedInAttendee { export function useAttendees(festivalAddress: string) { const search = ref('') - const attendees = computed(() => - festivalState.user.festivalPoaps - .map((p) => ({ - address: p.data.attendee, - checkedInAt: Number(p.data.issuedAt), - })) - .sort((a, b) => b.checkedInAt - a.checkedInAt), - ) + const attendees = computed(() => { + const fromPoaps = festivalState.user.festivalPoaps.map((p) => ({ + address: p.data.attendee, + checkedInAt: Number(p.data.issuedAt), + })) + const seen = new Set(fromPoaps.map((a) => a.address.toLowerCase())) + + // POAP data only refreshes on boot loads, so bridge the gap with rows the + // chain already confirmed checked-in (event-fed attendees array) and this + // device's in-flight check-ins. Their exact timestamp arrives with the + // POAP on the next load; until then they're simply "just now". + const nowSec = Math.floor(Date.now() / 1000) + const bridged: CheckedInAttendee[] = [] + for (const a of festivalState.festival?.attendees ?? []) { + if (a.isCheckedIn && !seen.has(a.address.toLowerCase())) { + seen.add(a.address.toLowerCase()) + bridged.push({ address: a.address, checkedInAt: nowSec }) + } + } + for (const addr of pendingCheckins()) { + if (!seen.has(addr)) { + seen.add(addr) + bridged.push({ address: addr as `0x${string}`, checkedInAt: nowSec }) + } + } + + return [...fromPoaps, ...bridged].sort((a, b) => b.checkedInAt - a.checkedInAt) + }) const filtered = computed(() => { const q = search.value.trim().toLowerCase() diff --git a/packages/admin/app/composables/useBootLoad.ts b/packages/admin/app/composables/useBootLoad.ts index ef26a7f..d65b847 100644 --- a/packages/admin/app/composables/useBootLoad.ts +++ b/packages/admin/app/composables/useBootLoad.ts @@ -14,12 +14,25 @@ import { persistToCache, type SessionEntry, } from '@festival/shared/cache/festival-state' +import { mergeAttendees, mergeSessions, mergePoaps, maxBig } from '@festival/shared/cache/merge' import type { ContractRole } from '@festival/shared/permissions' interface BootLoadOptions { at?: 'best' | 'finalized' } +/** + * A load that started for the previous account must not write user fields + * after a switch, so every user write checks this first. Global festival + * state stays safe to write from any run. + */ +function userStillCurrent(userAddress: `0x${string}` | null): boolean { + return ( + (festivalState.user.address?.toLowerCase() ?? null) === + (userAddress?.toLowerCase() ?? null) + ) +} + const ROLE_ORDER: { hash: `0x${string}`; label: ContractRole }[] = [ { hash: ROLES.DEFAULT_ADMIN_ROLE, label: 'ADMIN' }, { hash: ROLES.MANAGER_ROLE, label: 'MANAGER' }, @@ -41,6 +54,19 @@ export async function bootLoadAdmin( ): Promise { festivalState.loading = true festivalState.error = null + + // Account switch: the previous user's per-user fields must not leak into + // the new user's view or win the per-user merges below — resetting makes + // those merges start from empty for a new account. + const sameUser = + (festivalState.user.address?.toLowerCase() ?? null) === + (userAddress?.toLowerCase() ?? null) + if (!sameUser) { + festivalState.user.ticketTokenId = 0n + festivalState.user.festivalPoaps = [] + festivalState.user.sessionPoaps = [] + festivalState.user.roles = [] + } festivalState.user.address = userAddress // Cache-first paint. Show last-known data instantly while chain reads run. @@ -131,37 +157,55 @@ async function runRound1( festivalState.festival = { address: festivalAddress, - details, + details: { + ...details, + registeredCount: maxBig( + festivalState.festival?.details.registeredCount ?? 0n, + details.registeredCount, + ), + }, metadata: festivalState.festival?.metadata ?? null, - attendees: attendeesRaw[0].map((a, i) => ({ - address: a, - isCheckedIn: attendeesRaw[1][i] ?? false, - })), + attendees: mergeAttendees( + festivalState.festival?.attendees ?? [], + attendeesRaw[0].map((a, i) => ({ + address: a, + isCheckedIn: attendeesRaw[1][i] ?? false, + })), + ), } // User's roles. Derived locally from the per-role hasRole results. - festivalState.user.roles = ROLE_ORDER.filter((_, i) => hasRoleRaw[i]).map((r) => r.label) + if (userStillCurrent(userAddress)) { + festivalState.user.roles = ROLE_ORDER.filter((_, i) => hasRoleRaw[i]).map((r) => r.label) + } // Stub role holders. R2 fills members per role. festivalState.roles = ROLE_ORDER.map(({ hash }) => ({ role: hash, members: [] })) - // Stub session entries; R2 fills details/attendees. - festivalState.sessions = sessionsRaw.map((address) => ({ - address, - details: { - metadataCid: '0x0000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`, - creator: '0x0000000000000000000000000000000000000000' as `0x${string}`, - poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`, - parentFestival: festivalAddress, - startTime: 0n, - endTime: 0n, - cancelled: false, - registeredCount: 0n, - }, - metadata: null, - attendees: [], - poapTokenIds: [], - })) + // Stub session entries; R2 upgrades via merge. mergeSessions keeps any + // already-real entry (from cache or a prior load) from regressing to a stub. + festivalState.sessions = mergeSessions( + festivalState.sessions, + sessionsRaw.map((address) => ({ + address, + details: { + metadataCid: '0x0000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`, + creator: '0x0000000000000000000000000000000000000000' as `0x${string}`, + poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`, + parentFestival: festivalAddress, + startTime: 0n, + endTime: 0n, + cancelled: false, + registeredCount: 0n, + // Zero so keepPositive never lets a stub beat the real chain value. + flagCount: 0n, + flagThreshold: 0n, + }, + metadata: null, + attendees: [], + poapTokenIds: [], + })), + ) if (isNonZeroCid(details.metadataCid)) { void fetchFestivalMetadata(details.metadataCid) @@ -236,13 +280,29 @@ async function runRound2( flagThreshold: results[off + 3] as bigint, } - const entry = festivalState.sessions[i] + // Find by address, not by index: mergeSessions can reorder the array + // (union by address), so positional indexing would write to the wrong + // session on a warm re-run. + const addr = sessionAddrs[i]! + const entry = festivalState.sessions.find( + (s) => s.address.toLowerCase() === addr.toLowerCase(), + ) if (entry) { - entry.details = details - entry.attendees = attendeesRaw[0].map((a, idx) => ({ - address: a, - isCheckedIn: attendeesRaw[1][idx] ?? false, - })) + // Same latches mergeSession applies elsewhere. A stale read cannot + // uncancel a session, shrink its count, or roll back its flags. + entry.details = { + ...details, + cancelled: entry.details.cancelled || details.cancelled, + registeredCount: maxBig(entry.details.registeredCount, details.registeredCount), + flagCount: maxBig(entry.details.flagCount, details.flagCount), + } + entry.attendees = mergeAttendees( + entry.attendees, + attendeesRaw[0].map((a, idx) => ({ + address: a, + isCheckedIn: attendeesRaw[1][idx] ?? false, + })), + ) if (isNonZeroCid(details.metadataCid)) { metadataTargets.push({ address: entry.address, cid: details.metadataCid }) } @@ -269,11 +329,15 @@ async function runRound2( // list view shows all check-in timestamps). const festPoapOffset = memberOffset if (festPoapTokenIds.length > 0) { - festivalState.user.festivalPoaps = festPoapTokenIds.map((tokenId, idx) => ({ - poapContract: FESTIVAL_POAP_ADDRESS, - tokenId, - data: results[festPoapOffset + idx] as POAPData, - })) + // Union: mints are monotonic, so a stale read can't drop a POAP. + festivalState.user.festivalPoaps = mergePoaps( + festivalState.user.festivalPoaps, + festPoapTokenIds.map((tokenId, idx) => ({ + poapContract: FESTIVAL_POAP_ADDRESS, + tokenId, + data: results[festPoapOffset + idx] as POAPData, + })), + ) } } diff --git a/packages/admin/app/composables/useCheckIn.ts b/packages/admin/app/composables/useCheckIn.ts index f75ff79..4a67835 100644 --- a/packages/admin/app/composables/useCheckIn.ts +++ b/packages/admin/app/composables/useCheckIn.ts @@ -6,6 +6,7 @@ import { readIsRegistered } from "@festival/shared/contracts/festival-reads"; import { batchRead } from "@festival/shared/contracts/multicall"; import { formatTxError } from "@festival/shared/contracts/errors"; import { useWalletStore } from "@festival/shared/host/wallet"; +import { addPending, dropPending } from "@festival/shared/cache/pending"; import { shortenAddress, ss58ToH160, @@ -120,9 +121,11 @@ export function useCheckIn(festivalAddress: string) { txStatus.value = "preparing"; error.value = null; + // Captured before the tx so a late failure still drops the right key, + // even if the operator already moved on to the next attendee. + const attendeeH160 = ss58ToH160(attendeeSS58.value); try { const wallet = useWalletStore(); - const attendeeH160 = ss58ToH160(attendeeSS58.value); const registered = accountStatus.value?.registered ?? false; const fnName = registered ? "checkIn" : "manualCheckIn"; @@ -135,6 +138,9 @@ export function useCheckIn(festivalAddress: string) { walletAddress: wallet.address, onStatus: (s) => { txStatus.value = s; + // Attendee list shows the check-in immediately; the overlay rolls + // back on failure and is GC'd once the CheckedIn event confirms. + if (s === "broadcasting") addPending("checkin", attendeeH160); if (s === "in-block") { addRecentCheckin({ address: shortenAddress(attendeeSS58.value!), @@ -147,6 +153,7 @@ export function useCheckIn(festivalAddress: string) { }, }); } catch (e: any) { + dropPending("checkin", attendeeH160); txStatus.value = "error"; error.value = formatTxError(e); errorSource.value = "transaction"; @@ -177,6 +184,7 @@ export function useCheckIn(festivalAddress: string) { walletAddress: wallet.address, onStatus: (s) => { txStatus.value = s; + if (s === "broadcasting") addPending("checkin", attendeeH160); if (s === "in-block") { addRecentCheckin({ address: toDisplaySs58(address), @@ -188,6 +196,7 @@ export function useCheckIn(festivalAddress: string) { }, }); } catch (e: any) { + dropPending("checkin", toH160(address)); txStatus.value = "error"; error.value = formatTxError(e); throw new Error(error.value); diff --git a/packages/admin/app/composables/useSubEvents.ts b/packages/admin/app/composables/useSubEvents.ts index ac839ad..a19431e 100644 --- a/packages/admin/app/composables/useSubEvents.ts +++ b/packages/admin/app/composables/useSubEvents.ts @@ -6,7 +6,8 @@ import { FestivalABI } from '@festival/shared/contracts/abis' import { formatTxError } from '@festival/shared/contracts/errors' import { useWalletStore } from '@festival/shared/host/wallet' import { walletAddressToH160 } from '@festival/shared/utils/address' -import { festivalState } from '@festival/shared/cache/festival-state' +import { festivalState, type SessionEntry } from '@festival/shared/cache/festival-state' +import { addPending, dropPending, hasPending, pendingSessions, draftSessionEntry } from '@festival/shared/cache/pending' import { bootLoadAdmin } from './useBootLoad' export interface SubEventListItem { @@ -34,18 +35,33 @@ const FLAG_THRESHOLD_FALLBACK = 5 export function useSubEvents(_festivalAddress: string) { const txStatus = ref('idle') - const subEvents = computed(() => - festivalState.sessions.map((s) => ({ + const subEvents = computed(() => { + const toView = (s: SessionEntry): SubEventListItem => ({ address: s.address, metadata: s.metadata ?? { ...DEFAULT_METADATA, name: `Sub-Event ${s.address.slice(0, 8)}` }, registeredCount: Number(s.details.registeredCount), startTime: Number(s.details.startTime), endTime: Number(s.details.endTime), - cancelled: s.details.cancelled, + // A cancel tx in flight on this device shows as cancelled immediately; + // it rolls back if the tx fails and is GC'd once the chain confirms. + cancelled: s.details.cancelled || hasPending('cancelSession', s.address), flagCount: Number(s.details.flagCount), flagThreshold: Number(s.details.flagThreshold) || FLAG_THRESHOLD_FALLBACK, - })), - ) + }) + + const confirmed = festivalState.sessions.map(toView) + // Cancelled sessions don't count: a same-metadata recreate shares their + // CID and must not hide the new draft. + const confirmedCids = new Set( + festivalState.sessions + .filter((s) => !s.details.cancelled) + .map((s) => s.details.metadataCid.toLowerCase()), + ) + const drafts = pendingSessions() + .filter((s) => !confirmedCids.has(s.details.metadataCid.toLowerCase())) + .map(toView) + return [...confirmed, ...drafts] + }) const isLoading = computed(() => festivalState.loading) @@ -61,6 +77,7 @@ export function useSubEvents(_festivalAddress: string) { startTimestamp: bigint, endTimestamp: bigint, festivalPoapTokenId: bigint, + draftMetadata?: SubEventMetadata, ): Promise { txStatus.value = 'preparing' try { @@ -72,11 +89,32 @@ export function useSubEvents(_festivalAddress: string) { args: [metadataCid, startTimestamp, endTimestamp, festivalPoapTokenId], signer: wallet.getSigner(), walletAddress: wallet.address, - onStatus: (s) => { txStatus.value = s }, + onStatus: (s) => { + txStatus.value = s + // Draft renders in the list immediately; the confirmed entry with + // this CID supersedes it and promotion GCs the draft. + if (s === 'broadcasting') { + addPending('session', metadataCid, draftSessionEntry( + metadataCid, startTimestamp, endTimestamp, + walletAddressToH160(wallet.address), draftMetadata, + )) + } + }, }) await reload() - return subEvents.value[subEvents.value.length - 1]?.address || null + // The tx succeeded, so the draft has done its job either way. + dropPending('session', metadataCid) + // The created session is the live one carrying this CID. Fall back to a + // cancelled match for the rare create then instant cancel case. + const byCid = (live: boolean) => + festivalState.sessions.find( + (s) => + (!live || !s.details.cancelled) && + s.details.metadataCid.toLowerCase() === metadataCid.toLowerCase(), + ) + return (byCid(true) ?? byCid(false))?.address ?? null } catch (e) { + dropPending('session', metadataCid) txStatus.value = 'error' throw new Error(formatTxError(e)) } @@ -84,7 +122,6 @@ export function useSubEvents(_festivalAddress: string) { async function cancelSession(sessionAddress: string): Promise { txStatus.value = 'preparing' - const target = sessionAddress.toLowerCase() try { const wallet = useWalletStore() await writeContract({ @@ -94,23 +131,22 @@ export function useSubEvents(_festivalAddress: string) { args: [sessionAddress as `0x${string}`], signer: wallet.getSigner(), walletAddress: wallet.address, - onStatus: (s) => { txStatus.value = s }, + onStatus: (s) => { + txStatus.value = s + // Optimistic via the pending overlay, never a direct state write: + // under the cancelled-latch a speculative write could outlive a + // failed tx forever. The overlay rolls back on failure instead. + if (s === 'broadcasting') addPending('cancelSession', sessionAddress) + }, }) - markCancelledLocally(target) reload() } catch (e) { + dropPending('cancelSession', sessionAddress) txStatus.value = 'error' throw new Error(formatTxError(e)) } } - function markCancelledLocally(targetLower: string) { - const entry = festivalState.sessions.find((s) => s.address.toLowerCase() === targetLower) - if (entry) { - entry.details = { ...entry.details, cancelled: true } - } - } - return { subEvents, isLoading, txStatus, createSession, cancelSession, reload } } diff --git a/packages/admin/app/layouts/festival.vue b/packages/admin/app/layouts/festival.vue index e3b32b8..9f7ad03 100644 --- a/packages/admin/app/layouts/festival.vue +++ b/packages/admin/app/layouts/festival.vue @@ -10,6 +10,8 @@ import { useAttendees } from '~/composables/useAttendees' import { bootLoadAdmin } from '~/composables/useBootLoad' import { useFestivalWatcher } from '@festival/shared/cache/useFestivalWatcher' import { useVisibilityReconcile } from '@festival/shared/cache/visibility' +import { startCachePersistence, hydrateLastKnown } from '@festival/shared/cache/festival-state' +import { startPendingReconcile } from '@festival/shared/cache/pending' import { useMyAddressModal } from '~/composables/useMyAddressModal' const myAddressModal = useMyAddressModal() @@ -43,6 +45,15 @@ router.afterEach(() => { mobileMenuOpen.value = false }) const isDev = import.meta.dev const driftError = ref(null) +// Persist every state mutation, and paint last-known state before the wallet +// resolves (cache-first). +startCachePersistence() +void hydrateLastKnown(address.value as `0x${string}`) + +// Promote/GC optimistic pending entries (check-ins, drafts, cancels) as the +// confirmed tier catches up. +startPendingReconcile() + // Cold-load admin festival state in two Multicall round-trips. Re-runs on // wallet account switch and route-address change. function fireBootLoad() { @@ -55,7 +66,7 @@ watch(address, fireBootLoad) // Real-time contract event subscription. `deferWhileLoading` lets bootLoad // finish first so its reads don't race the watcher's chainHead follow init. -useFestivalWatcher(address.value as `0x${string}`, { +const watcher = useFestivalWatcher(address.value as `0x${string}`, { onDriftDetected: (msg) => { driftError.value = msg setTimeout(() => { driftError.value = null }, 8000) @@ -66,9 +77,11 @@ useFestivalWatcher(address.value as `0x${string}`, { // Visibility change as safety net. Reads at best, like every other state // source: all festival state is monotonic, so a finalized read could only // regress fresher best-derived state (e.g. revert a just-landed check-in). -useVisibilityReconcile(() => { +useVisibilityReconcile(async () => { const userH160 = wallet.isConnected ? walletAddressToH160(wallet.address) : null - return bootLoadAdmin(address.value as `0x${string}`, userH160) + await bootLoadAdmin(address.value as `0x${string}`, userH160) + // The chainHead follow may have died silently while backgrounded; re-open it. + watcher?.restart() }) function shortenAddr(addr: string) { diff --git a/packages/admin/app/pages/festival/[address]/sub-events/create.vue b/packages/admin/app/pages/festival/[address]/sub-events/create.vue index 382fc9d..13a9b6a 100644 --- a/packages/admin/app/pages/festival/[address]/sub-events/create.vue +++ b/packages/admin/app/pages/festival/[address]/sub-events/create.vue @@ -75,7 +75,7 @@ async function submit() { const startTs = BigInt(berlinFormToUnix(form.startDate, form.startTime)) const endTs = BigInt(berlinFormToUnix(form.endDate, form.endTime)) - const addr = await createSession(bytes32, startTs, endTs, 0n) + const addr = await createSession(bytes32, startTs, endTs, 0n, metadata) createdAddress.value = addr || address } catch (e: any) { txStatus.value = 'error' diff --git a/packages/attendee/app/app.vue b/packages/attendee/app/app.vue index 8637d4f..2fce6ec 100644 --- a/packages/attendee/app/app.vue +++ b/packages/attendee/app/app.vue @@ -11,6 +11,8 @@ import { useFestival } from '~/composables/useFestival' import { useSubEvents } from '~/composables/useSubEvents' import { usePoaps } from '~/composables/usePoaps' import { bootLoadAttendee } from '~/composables/useBootLoad' +import { startCachePersistence, hydrateLastKnown } from '@festival/shared/cache/festival-state' +import { startPendingReconcile } from '@festival/shared/cache/pending' import { useFestivalWatcher } from '@festival/shared/cache/useFestivalWatcher' import { useVisibilityReconcile } from '@festival/shared/cache/visibility' import { useAnnouncements } from '~/composables/useAnnouncements' @@ -69,6 +71,17 @@ const userH160 = computed(() => { // skip the initial fire when the wallet hasn't connected yet — the // `wallet.address` watcher below will run it once the user resolves, // avoiding a duplicate bootLoad (initial + on-connect). +// Persist every festivalState mutation (watcher events, optimistic flips, boot +// reads), not just boot-load success, so a cold restart paints last-known state. +startCachePersistence() + +// Cache-first paint: hydrate last-known state immediately, before the wallet +// resolves, so a slow or failed connection doesn't leave the UI blank. +void hydrateLastKnown(FESTIVAL_ADDRESS) + +// Promote/GC optimistic pending entries as the confirmed tier catches up. +startPendingReconcile() + function fireBootLoad() { if (!wallet.isConnected) return const userH160 = walletAddressToH160(wallet.address) @@ -84,7 +97,7 @@ const announcements = useAnnouncements() // race with our `ReviveApi.call` reads — bootLoad-first means at least the // initial state lands before the watcher's heavier follow-init might trip // the host's rate limiter. -useFestivalWatcher(FESTIVAL_ADDRESS, { +const watcher = useFestivalWatcher(FESTIVAL_ADDRESS, { deferWhileLoading: festival.isLoading, onChannelMetadataUpdated: () => { void announcements.reload() }, }) @@ -94,10 +107,12 @@ useFestivalWatcher(FESTIVAL_ADDRESS, { // tracking): all festival state is monotonic, so a finalized read could only // regress fresher best-derived state (e.g. revert a just-landed check-in for // the length of the finality lag). -useVisibilityReconcile(() => { +useVisibilityReconcile(async () => { const userH160 = wallet.isConnected ? walletAddressToH160(wallet.address) : null void announcements.reloadIfChanged() - return bootLoadAttendee(userH160) + await bootLoadAttendee(userH160) + // The chainHead follow may have died silently while backgrounded; re-open it. + watcher?.restart() }) function shortenAddr(addr: string) { diff --git a/packages/attendee/app/composables/useAnnouncements.ts b/packages/attendee/app/composables/useAnnouncements.ts index 7178f3f..0722802 100644 --- a/packages/attendee/app/composables/useAnnouncements.ts +++ b/packages/attendee/app/composables/useAnnouncements.ts @@ -1,4 +1,5 @@ import { ref, computed, watch } from 'vue' +import { usePersistentRef } from '@festival/shared/cache/persistent' import type { ChannelMetadata, AnnouncementBody } from '@festival/shared/metadata/schemas' import { fetchAnnouncementBodies } from '@festival/shared/metadata/announcementBodies' import { loadChannelFromChain } from '@festival/shared/metadata/channel' @@ -24,9 +25,8 @@ const error = ref(null) // delivered automatically once the user checks in. const LAST_READ_KEY = `conferenceApp:lastReadCid:${channelId}` -const lastReadCid = ref( - typeof window === 'undefined' ? null : window.localStorage.getItem(LAST_READ_KEY), -) +// Durable across WebView eviction; write-through is automatic. +const lastReadCid = usePersistentRef(LAST_READ_KEY, null) // Computed const announcements = computed(() => channel.value?.announcements ?? []) @@ -116,9 +116,6 @@ function markRead() { const newest = items[items.length - 1] if (!newest) return lastReadCid.value = newest - if (typeof window !== 'undefined') { - window.localStorage.setItem(LAST_READ_KEY, newest) - } } export function useAnnouncements() { diff --git a/packages/attendee/app/composables/useBookmarks.ts b/packages/attendee/app/composables/useBookmarks.ts index f2a2844..fc2c5d2 100644 --- a/packages/attendee/app/composables/useBookmarks.ts +++ b/packages/attendee/app/composables/useBookmarks.ts @@ -1,8 +1,7 @@ import { ref } from 'vue' +import { usePersistentRef } from '@festival/shared/cache/persistent' import { useScheduledAlerts, type ScheduleAlertOutcome } from './useScheduledAlerts' -const STORAGE_KEY = 'festival-bookmarks' - export interface BookmarkPayload { startMs: number title: string @@ -15,21 +14,8 @@ export type BookmarkAlert = | { kind: 'permission-denied'; ts: number } | null -function loadBookmarks(): string[] { - try { - const stored = localStorage.getItem(STORAGE_KEY) - return stored ? JSON.parse(stored) : [] - } catch { - return [] - } -} - -function persistBookmarks(ids: string[]) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)) -} - -// Shared state across components -const bookmarkedIds = ref(typeof window !== 'undefined' ? loadBookmarks() : []) +// Shared state across components. Durable; write-through is automatic. +const bookmarkedIds = usePersistentRef('festival-bookmarks', []) // Set when a schedule attempt fails in a way the UI should surface. `ts` // changes on every set so the layout-level toast re-fires even when the // same `kind` is set twice in a row. @@ -44,7 +30,6 @@ export function useBookmarks() { } else { bookmarkedIds.value.splice(idx, 1) } - persistBookmarks(bookmarkedIds.value) const alerts = useScheduledAlerts() if (adding && payload) { diff --git a/packages/attendee/app/composables/useBootLoad.ts b/packages/attendee/app/composables/useBootLoad.ts index df51d26..21ecdc2 100644 --- a/packages/attendee/app/composables/useBootLoad.ts +++ b/packages/attendee/app/composables/useBootLoad.ts @@ -12,6 +12,7 @@ import { hydrateSubEventMetadata } from '@festival/shared/metadata/schemas' import { useBulletinStorage } from '@festival/shared/metadata/bulletin' import { fetchInChunks } from '@festival/shared/utils/chunked' import { festivalState, hydrateFromCache, persistToCache, type SessionEntry } from '@festival/shared/cache/festival-state' +import { mergeAttendees, mergeSessions, mergePoaps, maxBig, keepPositive } from '@festival/shared/cache/merge' interface BootLoadOptions { /** Block tag for the chain reads. Default 'best' for snappy UX. */ @@ -29,6 +30,18 @@ let current: Promise | null = null let trailing: Promise | null = null let trailingArgs: [`0x${string}` | null, BootLoadOptions] | null = null +/** + * A load that started for the previous account must not write user fields + * after a switch, so every user write checks this first. Global festival + * state stays safe to write from any run. + */ +function userStillCurrent(userAddress: `0x${string}` | null): boolean { + return ( + (festivalState.user.address?.toLowerCase() ?? null) === + (userAddress?.toLowerCase() ?? null) + ) +} + /** * Cold-load the entire festival state in up to three Multicall3 round-trips. * Each round is keyed on the previous round's results: @@ -38,6 +51,10 @@ let trailingArgs: [`0x${string}` | null, BootLoadOptions] | null = null * R2 — per session (details, attendees, POAP token list) and per festival POAP * token (getPOAPData). * R3 — per session POAP token (getPOAPData), only when any exist. + * + * Concurrent loads are safe: every write into festivalState goes through the + * monotonic merge (see cache/merge.ts), so interleaved or out-of-order runs + * can only upgrade state, never regress it — no single-flight guard needed. */ export function bootLoadAttendee( userAddress: `0x${string}` | null, @@ -70,6 +87,19 @@ async function runBootLoad( ): Promise { festivalState.loading = true festivalState.error = null + + // Account switch: the previous user's per-user fields must not leak into + // the new user's view or win the per-user merges below — resetting makes + // those merges start from empty for a new account. + const sameUser = + (festivalState.user.address?.toLowerCase() ?? null) === + (userAddress?.toLowerCase() ?? null) + if (!sameUser) { + festivalState.user.ticketTokenId = 0n + festivalState.user.festivalPoaps = [] + festivalState.user.sessionPoaps = [] + festivalState.user.roles = [] + } festivalState.user.address = userAddress // Cache-first paint. Show last-known data instantly while chain reads run. @@ -150,35 +180,51 @@ async function runRound1( })) // Metadata is fetched off-chain (Bulletin) below and stays null until it lands. + // Attendees merge (check-in latches) and registeredCount only advances, so a + // stale read can't drop a just-applied registration/check-in. festivalState.festival = { address: FESTIVAL_ADDRESS, - details, + details: { + ...details, + registeredCount: maxBig( + festivalState.festival?.details.registeredCount ?? 0n, + details.registeredCount, + ), + }, metadata: festivalState.festival?.metadata ?? null, - attendees, + attendees: mergeAttendees(festivalState.festival?.attendees ?? [], attendees), } - festivalState.user.ticketTokenId = ticketRaw + // A stale read can't take a known ticket back to zero (no unregister on-chain). + if (userStillCurrent(userAddress)) { + festivalState.user.ticketTokenId = keepPositive(festivalState.user.ticketTokenId, ticketRaw) + } // Stub session entries up front so consumers (e.g. the session detail page) - // can resolve `getByAddress(addr)` immediately; R2 fills in the details. - festivalState.sessions = sessionsRaw.map((address) => ({ - address, - details: { - metadataCid: '0x0000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`, - creator: '0x0000000000000000000000000000000000000000' as `0x${string}`, - poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`, - parentFestival: FESTIVAL_ADDRESS, - startTime: 0n, - endTime: 0n, - cancelled: false, - registeredCount: 0n, - flagCount: 0n, - flagThreshold: 5n, - }, - metadata: null, - attendees: [], - poapTokenIds: [], - })) + // can resolve `getByAddress(addr)` immediately; R2 upgrades them via merge. + // mergeSessions keeps any already-real entry from regressing to a stub. + festivalState.sessions = mergeSessions( + festivalState.sessions, + sessionsRaw.map((address) => ({ + address, + details: { + metadataCid: '0x0000000000000000000000000000000000000000000000000000000000000000' as `0x${string}`, + creator: '0x0000000000000000000000000000000000000000' as `0x${string}`, + poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`, + parentFestival: FESTIVAL_ADDRESS, + startTime: 0n, + endTime: 0n, + cancelled: false, + registeredCount: 0n, + // Zero so keepPositive never lets a stub beat the real chain value. + flagCount: 0n, + flagThreshold: 0n, + }, + metadata: null, + attendees: [], + poapTokenIds: [], + })), + ) // Off-chain fetch; doesn't count against the chainHead rate budget. Fire and forget. if (isNonZeroCid(details.metadataCid)) { @@ -262,19 +308,25 @@ async function runRound2( } }) - festivalState.sessions = sessionEntries + festivalState.sessions = mergeSessions(festivalState.sessions, sessionEntries) - // Festival POAP data, filtered to the connected user. + // Festival POAP data, filtered to the connected user. Union with what we + // already hold: mints are monotonic, so a stale read can't drop a POAP. const festPoapDataOffset = sessionAddrs.length * STRIDE const userLower = userAddress?.toLowerCase() ?? null - festivalState.user.festivalPoaps = festPoapTokenIds - .map((tokenId, idx) => { - const data = results[festPoapDataOffset + idx] as POAPData - return { poapContract: FESTIVAL_POAP_ADDRESS, tokenId, data } - }) - .filter((entry) => - userLower ? entry.data.attendee.toLowerCase() === userLower : false, + if (userStillCurrent(userAddress)) { + festivalState.user.festivalPoaps = mergePoaps( + festivalState.user.festivalPoaps, + festPoapTokenIds + .map((tokenId, idx) => { + const data = results[festPoapDataOffset + idx] as POAPData + return { poapContract: FESTIVAL_POAP_ADDRESS, tokenId, data } + }) + .filter((entry) => + userLower ? entry.data.attendee.toLowerCase() === userLower : false, + ), ) + } // Per-session Bulletin metadata fetches; off-chain, no chainHead rate cost. const metadataTargets = sessionEntries.filter((e) => isNonZeroCid(e.details.metadataCid)) @@ -320,15 +372,20 @@ async function runRound3( } } - festivalState.user.sessionPoaps = flat - .filter((f) => - userLower ? f.data.attendee.toLowerCase() === userLower : false, + if (userStillCurrent(userAddress)) { + festivalState.user.sessionPoaps = mergePoaps( + festivalState.user.sessionPoaps, + flat + .filter((f) => + userLower ? f.data.attendee.toLowerCase() === userLower : false, + ) + .map((f) => ({ + poapContract: SUB_EVENT_POAP_ADDRESS, + tokenId: f.tokenId, + data: f.data, + })), ) - .map((f) => ({ - poapContract: SUB_EVENT_POAP_ADDRESS, - tokenId: f.tokenId, - data: f.data, - })) + } } async function fetchSessionMetadata( diff --git a/packages/attendee/app/composables/useHiddenSessions.ts b/packages/attendee/app/composables/useHiddenSessions.ts index c758d8c..7a67892 100644 --- a/packages/attendee/app/composables/useHiddenSessions.ts +++ b/packages/attendee/app/composables/useHiddenSessions.ts @@ -1,28 +1,13 @@ -import { ref } from 'vue' +import { usePersistentRef } from '@festival/shared/cache/persistent' -const STORAGE_KEY = 'festival-hidden-sessions' - -function loadHidden(): string[] { - try { - const stored = localStorage.getItem(STORAGE_KEY) - return stored ? JSON.parse(stored) : [] - } catch { - return [] - } -} - -function persistHidden(addresses: string[]) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(addresses)) -} - -const hiddenAddresses = ref(typeof window !== 'undefined' ? loadHidden() : []) +// Durable across mobile WebView eviction; write-through is automatic. +const hiddenAddresses = usePersistentRef('festival-hidden-sessions', []) export function useHiddenSessions() { function hide(address: string) { const lower = address.toLowerCase() if (!hiddenAddresses.value.includes(lower)) { hiddenAddresses.value.push(lower) - persistHidden(hiddenAddresses.value) } } diff --git a/packages/attendee/app/composables/useOnboardingSeen.ts b/packages/attendee/app/composables/useOnboardingSeen.ts index 2f98745..be6de2a 100644 --- a/packages/attendee/app/composables/useOnboardingSeen.ts +++ b/packages/attendee/app/composables/useOnboardingSeen.ts @@ -1,33 +1,16 @@ -import { ref } from 'vue' +import { usePersistentRef } from '@festival/shared/cache/persistent' -const STORAGE_KEY = 'festival-onboarding-seen' - -function load(): Set { - try { - const stored = localStorage.getItem(STORAGE_KEY) - return new Set(stored ? JSON.parse(stored) : []) - } catch { - return new Set() - } -} - -function persist(s: Set) { - localStorage.setItem(STORAGE_KEY, JSON.stringify([...s])) -} - -const seen = ref>( - typeof window !== 'undefined' ? load() : new Set(), -) +// Stored as a string array (unchanged on-disk format); durable, write-through. +const seen = usePersistentRef('festival-onboarding-seen', []) export function useOnboardingSeen() { function has(key: string): boolean { - return seen.value.has(key) + return seen.value.includes(key) } function markSeen(key: string) { - if (seen.value.has(key)) return - seen.value = new Set([...seen.value, key]) - persist(seen.value) + if (seen.value.includes(key)) return + seen.value.push(key) } return { has, markSeen } diff --git a/packages/attendee/app/composables/useRegistration.ts b/packages/attendee/app/composables/useRegistration.ts index c8f754e..006b599 100644 --- a/packages/attendee/app/composables/useRegistration.ts +++ b/packages/attendee/app/composables/useRegistration.ts @@ -1,4 +1,4 @@ -import { ref, computed, type WritableComputedRef } from 'vue' +import { ref, computed } from 'vue' import type { TxStatus } from '@festival/shared/contracts/write' import { writeContract } from '@festival/shared/contracts/write' import { FestivalABI } from '@festival/shared/contracts/abis' @@ -9,6 +9,7 @@ import { formatTxError } from '@festival/shared/contracts/errors' import { useWalletStore } from '@festival/shared/host/wallet' import { walletAddressToH160 } from '@festival/shared/utils/address' import { festivalState } from '@festival/shared/cache/festival-state' +import { hasPending, addPending, dropPending } from '@festival/shared/cache/pending' import { bootLoadAttendee } from './useBootLoad' // Tx-state stays local. It's a per-action signal, not festival state. @@ -16,11 +17,12 @@ const txStatus = ref('idle') const error = ref(null) /** - * Registration composable. Derives the user's status from - * `festivalState.festival.attendees` and `festivalState.user.ticketTokenId`. - * - * `isRegistered` and `isCheckedIn` are writable computed so the watcher's - * ref-write path keeps working until it mutates state directly. + * Registration composable. Two read tiers, never written directly: + * confirmed (`festivalState`, fed by chain reads + events through the + * monotonic merge) OR'd with this device's pending tx overlay. Optimism never + * touches the confirmed tier — under merge's never-drop semantics a + * speculative attendee row could outlive a failed tx forever; the pending + * entry instead rolls back on failure and is GC'd once the chain confirms. */ export function useRegistration(_festivalAddress: string) { function userLower(): string | null { @@ -34,36 +36,16 @@ export function useRegistration(_festivalAddress: string) { return festivalState.festival?.attendees.find((a) => a.address.toLowerCase() === ul) } - const isRegistered: WritableComputedRef = computed({ - get: () => { - // Either present in attendees OR the user holds a festival POAP. - return Boolean(findUserAttendee()) || festivalState.user.ticketTokenId > 0n - }, - set: (v) => { - // Keeps the ref-write contract: pushing the user into attendees is what - // makes the getter return true. - if (v && festivalState.user.address && festivalState.festival) { - const exists = festivalState.festival.attendees.some( - (a) => a.address.toLowerCase() === festivalState.user.address!.toLowerCase(), - ) - if (!exists) { - festivalState.festival.attendees.push({ - address: festivalState.user.address, - isCheckedIn: false, - }) - } - } - }, + const isRegistered = computed(() => { + const ul = userLower() + if (ul && hasPending('register', ul)) return true + return Boolean(findUserAttendee()) || festivalState.user.ticketTokenId > 0n }) - const isCheckedIn: WritableComputedRef = computed({ - get: () => findUserAttendee()?.isCheckedIn ?? false, - set: (v) => { - const ul = userLower() - if (!ul || !festivalState.festival) return - const a = festivalState.festival.attendees.find((x) => x.address.toLowerCase() === ul) - if (a) a.isCheckedIn = v - }, + const isCheckedIn = computed(() => { + const ul = userLower() + if (ul && hasPending('checkin', ul)) return true + return findUserAttendee()?.isCheckedIn ?? false }) const ticketTokenId = computed(() => { @@ -74,8 +56,12 @@ export function useRegistration(_festivalAddress: string) { async function register() { error.value = null txStatus.value = 'preparing' + const wallet = useWalletStore() + const userH160 = + hasDeployedContracts() && wallet.isConnected + ? walletAddressToH160(wallet.address) + : null try { - const wallet = useWalletStore() await writeContract({ address: FESTIVAL_ADDRESS as `0x${string}`, abi: FestivalABI, @@ -85,14 +71,17 @@ export function useRegistration(_festivalAddress: string) { walletAddress: wallet.address, onStatus: (s) => { txStatus.value = s - if (s === 'in-block') { - isRegistered.value = true - } + // Optimistic from broadcast; the pending entry is GC'd once the + // Registered event / next read confirms, and dropped (rollback) by + // the catch below on failure. The confirmed tier is never written + // speculatively. + if (s === 'broadcasting' && userH160) addPending('register', userH160) }, }) setTimeout(() => { txStatus.value = 'idle' }, 2000) } catch (e: any) { + if (userH160) dropPending('register', userH160) txStatus.value = 'error' error.value = formatTxError(e) } diff --git a/packages/attendee/app/composables/useScheduledAlerts.ts b/packages/attendee/app/composables/useScheduledAlerts.ts index f37e14e..dd8f66c 100644 --- a/packages/attendee/app/composables/useScheduledAlerts.ts +++ b/packages/attendee/app/composables/useScheduledAlerts.ts @@ -1,4 +1,3 @@ -import { ref } from 'vue' import { pushNotification, cancelNotification, @@ -6,6 +5,7 @@ import { type NotificationId, } from '@festival/shared/host/notifications' import { requestNotificationsPermission } from '@festival/shared/host/permissions' +import { usePersistentRef } from '@festival/shared/cache/persistent' const STORAGE_KEY = 'festival-scheduled-alert-ids' const LEAD_TIME_MS = 10 * 60_000 @@ -50,21 +50,9 @@ export type ScheduleAlertOutcome = type IdMap = Record -function loadMap(): IdMap { - if (typeof window === 'undefined') return {} - try { - const raw = localStorage.getItem(STORAGE_KEY) - return raw ? JSON.parse(raw) : {} - } catch { - return {} - } -} - -function persistMap(map: IdMap) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(map)) -} - -const idMap = ref(loadMap()) +// Durable: losing this map orphans uncancellable OS notifications, so it must +// survive WebView eviction, not just live in localStorage. +const idMap = usePersistentRef(STORAGE_KEY, {}) export function useScheduledAlerts() { async function schedule(input: ScheduleAlertInput): Promise { @@ -84,7 +72,6 @@ export function useScheduledAlerts() { if (result === null) return 'failed' idMap.value = { ...idMap.value, [input.id]: result } - persistMap(idMap.value) return 'scheduled' } @@ -97,7 +84,6 @@ export function useScheduledAlerts() { const next = { ...idMap.value } delete next[sessionId] idMap.value = next - persistMap(idMap.value) } return { schedule, cancel } diff --git a/packages/attendee/app/composables/useSessionWatcher.ts b/packages/attendee/app/composables/useSessionWatcher.ts index be479bc..56e3a0b 100644 --- a/packages/attendee/app/composables/useSessionWatcher.ts +++ b/packages/attendee/app/composables/useSessionWatcher.ts @@ -45,7 +45,7 @@ export function useSessionWatcher(sessionAddress: string) { const { retrievePlaintext } = useBulletinStorage() const raw = await retrievePlaintext(newCid) const metadata = hydrateSubEventMetadata(raw) - useSubEvents().patchSession(sessionAddress, { metadata }) + useSubEvents().patchSession(sessionAddress, { metadata, metadataCid: newCid }) } catch (e) { console.warn('[useSessionWatcher] metadata patch failed:', e) } diff --git a/packages/attendee/app/composables/useSubEventCheckIn.ts b/packages/attendee/app/composables/useSubEventCheckIn.ts index 1884e9f..4400f4f 100644 --- a/packages/attendee/app/composables/useSubEventCheckIn.ts +++ b/packages/attendee/app/composables/useSubEventCheckIn.ts @@ -5,6 +5,7 @@ import { FestivalABI } from '@festival/shared/contracts/abis' import { batchRead } from '@festival/shared/contracts/multicall' import { formatTxError } from '@festival/shared/contracts/errors' import { useWalletStore } from '@festival/shared/host/wallet' +import { addPending, dropPending, sessionScopedId } from '@festival/shared/cache/pending' import { shortenAddress, ss58ToH160, isValidSs58, isValidEvmAddress } from '@festival/shared/utils/address' export type SubEventCheckInStep = @@ -95,9 +96,12 @@ export function useSubEventCheckIn(subEventAddress: string) { txStatus.value = 'preparing' error.value = null + // Captured before the tx so a late failure still drops the right key, + // even if the operator already moved on to the next attendee. + const attendeeH160 = toH160(attendeeSS58.value) + const pendingId = sessionScopedId(attendeeH160, subEventAddress) try { const wallet = useWalletStore() - const attendeeH160 = toH160(attendeeSS58.value) const fn = accountStatus.value?.registered ? checkInSession : manualCheckInSession @@ -109,6 +113,9 @@ export function useSubEventCheckIn(subEventAddress: string) { walletAddress: wallet.address, onStatus: (s) => { txStatus.value = s + // Session-scoped overlay entry: rolls back on failure, GC'd once + // the session's attendee row confirms via the next read. + if (s === 'broadcasting') addPending('checkin', pendingId) if (s === 'in-block') { addRecentCheckin({ address: shortenAddress(attendeeSS58.value!), @@ -119,6 +126,7 @@ export function useSubEventCheckIn(subEventAddress: string) { }, }) } catch (e: any) { + dropPending('checkin', pendingId) txStatus.value = 'error' error.value = formatTxError(e) step.value = 'error' diff --git a/packages/attendee/app/composables/useSubEventManage.ts b/packages/attendee/app/composables/useSubEventManage.ts index c5bd7da..73851f1 100644 --- a/packages/attendee/app/composables/useSubEventManage.ts +++ b/packages/attendee/app/composables/useSubEventManage.ts @@ -15,6 +15,7 @@ import { formatTxError } from '@festival/shared/contracts/errors' import { useBulletinStorage } from '@festival/shared/metadata/bulletin' import { useWalletStore } from '@festival/shared/host/wallet' import { setCachedMetadata } from '@festival/shared/cache/cid-cache' +import { addPending, dropPending } from '@festival/shared/cache/pending' import { ss58ToH160, isValidSs58, isValidEvmAddress } from '@festival/shared/utils/address' import { useSubEvents } from './useSubEvents' @@ -144,6 +145,10 @@ export function useSubEventManage(address: string) { await cancelSession({ ...getWriteOpts(s => { txStatus.value = s + // Optimistic via the pending overlay; never written into the + // confirmed tier, where the cancelled-latch would make a failed + // cancel permanent. Rolls back in the catch below. + if (s === 'broadcasting') addPending('cancelSession', address) if (s === 'in-block' && details.value) { details.value = { ...details.value, cancelled: true } useSubEvents().reload() @@ -154,6 +159,7 @@ export function useSubEventManage(address: string) { }) setTimeout(() => { txStatus.value = 'idle' }, 2000) } catch (e) { + dropPending('cancelSession', address) txStatus.value = 'error' error.value = formatTxError(e) } diff --git a/packages/attendee/app/composables/useSubEvents.ts b/packages/attendee/app/composables/useSubEvents.ts index 67aef53..bc5ef39 100644 --- a/packages/attendee/app/composables/useSubEvents.ts +++ b/packages/attendee/app/composables/useSubEvents.ts @@ -2,7 +2,8 @@ import { computed } from 'vue' import type { SubEventMetadata } from '@festival/shared/metadata/schemas' import { useWalletStore } from '@festival/shared/host/wallet' import { walletAddressToH160 } from '@festival/shared/utils/address' -import { festivalState } from '@festival/shared/cache/festival-state' +import { festivalState, type SessionEntry } from '@festival/shared/cache/festival-state' +import { hasPending, pendingSessions, sessionScopedId } from '@festival/shared/cache/pending' import { bootLoadAttendee } from './useBootLoad' export interface AttendeeSubEvent { @@ -34,24 +35,45 @@ const DEFAULT_METADATA: SubEventMetadata = { export function useSubEvents() { const subEvents = computed(() => { const userLower = festivalState.user.address?.toLowerCase() ?? null - return festivalState.sessions - .filter((s) => !s.details.cancelled) - .map((s) => { - const userRow = userLower - ? s.attendees.find((a) => a.address.toLowerCase() === userLower) - : undefined - return { - address: s.address, - creator: s.details.creator, - metadata: s.metadata ?? { ...DEFAULT_METADATA, name: `Sub-Event ${s.address.slice(0, 8)}` }, - registeredCount: Number(s.details.registeredCount), - capacity: 0, - startTime: Number(s.details.startTime), - endTime: Number(s.details.endTime), - isRegistered: Boolean(userRow), - isCheckedIn: userRow?.isCheckedIn ?? false, - } - }) + + const toView = (s: SessionEntry): AttendeeSubEvent => { + const userRow = userLower + ? s.attendees.find((a) => a.address.toLowerCase() === userLower) + : undefined + // Confirmed row, or our own in flight check in for this session. + const pendingCheckIn = userLower + ? hasPending('checkin', sessionScopedId(userLower, s.address)) + : false + return { + address: s.address, + creator: s.details.creator, + metadata: s.metadata ?? { ...DEFAULT_METADATA, name: `Sub-Event ${s.address.slice(0, 8)}` }, + registeredCount: Number(s.details.registeredCount), + capacity: 0, + startTime: Number(s.details.startTime), + endTime: Number(s.details.endTime), + isRegistered: Boolean(userRow) || pendingCheckIn, + isCheckedIn: (userRow?.isCheckedIn ?? false) || pendingCheckIn, + } + } + + const confirmed = festivalState.sessions + .filter((s) => !s.details.cancelled && !hasPending('cancelSession', s.address)) + .map(toView) + + // Splice in this device's in flight drafts. A live confirmed entry with + // the same CID supersedes its draft. Cancelled ones do not count because + // a recreate with the same metadata shares their CID. + const confirmedCids = new Set( + festivalState.sessions + .filter((s) => !s.details.cancelled) + .map((s) => s.details.metadataCid.toLowerCase()), + ) + const drafts = pendingSessions() + .filter((s) => !confirmedCids.has(s.details.metadataCid.toLowerCase())) + .map(toView) + + return [...confirmed, ...drafts] }) const isLoading = computed(() => festivalState.loading) @@ -62,13 +84,20 @@ export function useSubEvents() { /** * Patch a single session's metadata in festivalState. Used by - * useSessionWatcher when MetadataUpdated fires. + * useSessionWatcher when MetadataUpdated fires. The CID travels with the + * metadata so the entry reflects which version it holds. */ - function patchSession(address: string, patch: Partial<{ metadata: SubEventMetadata }>) { + function patchSession( + address: string, + patch: Partial<{ metadata: SubEventMetadata; metadataCid: `0x${string}` }>, + ) { const target = address.toLowerCase() const entry = festivalState.sessions.find((s) => s.address.toLowerCase() === target) if (!entry) return if (patch.metadata) entry.metadata = patch.metadata + if (patch.metadataCid) { + entry.details = { ...entry.details, metadataCid: patch.metadataCid } + } } function reload(): Promise { diff --git a/packages/attendee/app/pages/sessions/create.vue b/packages/attendee/app/pages/sessions/create.vue index e830ef3..bf28856 100644 --- a/packages/attendee/app/pages/sessions/create.vue +++ b/packages/attendee/app/pages/sessions/create.vue @@ -17,6 +17,8 @@ import { useBulletinStorage } from "@festival/shared/metadata/bulletin"; import { formatTxError } from "@festival/shared/contracts/errors"; import { useWalletStore } from "@festival/shared/host/wallet"; import { ss58ToH160, isValidEvmAddress } from "@festival/shared/utils/address"; +import { festivalState } from "@festival/shared/cache/festival-state"; +import { addPending, dropPending, draftSessionEntry } from "@festival/shared/cache/pending"; import { encodeCoordLocation, resolveFullLocationLabel, @@ -245,6 +247,8 @@ async function submit() { return; error.value = null; submitValidationError.value = null; + // CID of the in-flight draft, visible to the catch for rollback. + let pendingCid: `0x${string}` | null = null; // Re-validate against the live festival window, catching clock drift between // picking a time and tapping submit. Location and badge are preserved. @@ -307,6 +311,13 @@ async function submit() { ), ); + const creatorH160 = ( + isValidEvmAddress(wallet.address) + ? wallet.address.toLowerCase() + : ss58ToH160(wallet.address).toLowerCase() + ) as `0x${string}`; + pendingCid = bytes32; + await writeContract({ address: FESTIVAL_ADDRESS as `0x${string}`, abi: FestivalABI, @@ -316,23 +327,37 @@ async function submit() { walletAddress: wallet.address, onStatus: (s) => { txStatus.value = s; + // The draft renders in lists immediately; the confirmed entry with + // this CID supersedes it, and the catch below rolls it back. + if (s === "broadcasting") { + addPending( + "session", + bytes32, + draftSessionEntry(bytes32, startTs, endTs, creatorH160, metadata), + ); + } }, }); - // Try to find the created sub-event address + // The tx succeeded, so the draft has done its job either way. + dropPending("session", bytes32); + // Resolve the created address by its CID, which is unique to this + // creation. Prefer a live match, fall back to a cancelled one for the + // rare create then instant cancel case. try { await reloadSubEvents(); - const userAddr = isValidEvmAddress(wallet.address) - ? wallet.address.toLowerCase() - : ss58ToH160(wallet.address).toLowerCase(); - const myEntry = subEvents.value.find( - (se) => se.creator.toLowerCase() === userAddr, - ); - createdAddress.value = myEntry?.address ?? FESTIVAL_ADDRESS; + const byCid = (live: boolean) => + festivalState.sessions.find( + (s) => + (!live || !s.details.cancelled) && + s.details.metadataCid.toLowerCase() === bytes32.toLowerCase(), + ); + createdAddress.value = (byCid(true) ?? byCid(false))?.address ?? FESTIVAL_ADDRESS; } catch { createdAddress.value = FESTIVAL_ADDRESS; } } catch (e: any) { + if (pendingCid) dropPending("session", pendingCid); txStatus.value = "error"; error.value = formatTxError(e); } diff --git a/packages/shared/cache/festival-state.ts b/packages/shared/cache/festival-state.ts index d752fa9..f45bb36 100644 --- a/packages/shared/cache/festival-state.ts +++ b/packages/shared/cache/festival-state.ts @@ -1,8 +1,9 @@ -import { reactive } from 'vue' +import { reactive, watch, effectScope } from 'vue' import type { FestivalDetails, SessionDetails, POAPData } from '../contracts/types' import type { FestivalMetadata, SubEventMetadata } from '../metadata/schemas' import type { ContractRole } from '../permissions' import { getStorage } from './storage' +import { mergeAttendees, mergeSessions, mergePoaps, keepPositive } from './merge' /** * Central festival state. Boot-load fills it; watchers and reconcile mutate it @@ -110,31 +111,30 @@ export function applyMetadataUpdated( export function applyRegistered(attendee: `0x${string}`): void { if (!festivalState.festival) return - festivalState.festival.details = { - ...festivalState.festival.details, - registeredCount: festivalState.festival.details.registeredCount + 1n, - } - const lower = attendee.toLowerCase() - const exists = festivalState.festival.attendees.some( - (a) => a.address.toLowerCase() === lower, + // Only count rows we have not seen. Reads return the count and the list + // from the same block, so a known row means the count already includes it. + // That keeps replayed events from inflating the number. + const known = festivalState.festival.attendees.some( + (a) => a.address.toLowerCase() === attendee.toLowerCase(), ) - if (!exists) { - festivalState.festival.attendees.push({ address: attendee, isCheckedIn: false }) + if (!known) { + festivalState.festival.details = { + ...festivalState.festival.details, + registeredCount: festivalState.festival.details.registeredCount + 1n, + } + festivalState.festival.attendees = mergeAttendees(festivalState.festival.attendees, [ + { address: attendee, isCheckedIn: false }, + ]) } } export function applyCheckedIn(attendee: `0x${string}`): void { if (!festivalState.festival) return - const lower = attendee.toLowerCase() - const row = festivalState.festival.attendees.find( - (a) => a.address.toLowerCase() === lower, - ) - if (row) { - row.isCheckedIn = true - } else { - // CheckedIn implies the attendee is registered. Append both states. - festivalState.festival.attendees.push({ address: attendee, isCheckedIn: true }) - } + // CheckedIn implies registered; mergeAttendees latches isCheckedIn true and + // appends the row if the Registered event was missed. + festivalState.festival.attendees = mergeAttendees(festivalState.festival.attendees, [ + { address: attendee, isCheckedIn: true }, + ]) } export function applyCapacityUpdated(newCapacity: number): void { @@ -163,28 +163,56 @@ export function applySessionCreated( creator: `0x${string}`, metadataCid: `0x${string}`, ): void { - const exists = festivalState.sessions.some( + // The watcher replays this event on resubscribe, so an entry we already + // hold may carry a NEWER cid than the creation one. Keep what we have in + // that case, the event only seeds brand new or zero stub entries. + const existing = festivalState.sessions.find( (s) => s.address.toLowerCase() === sessionAddress.toLowerCase(), ) - if (exists) return - festivalState.sessions.push({ - address: sessionAddress, - details: { - metadataCid, - creator, - poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`, - parentFestival: festivalState.festival?.address ?? '0x0000000000000000000000000000000000000000' as `0x${string}`, - startTime: 0n, - endTime: 0n, - cancelled: false, - registeredCount: 0n, - flagCount: 0n, - flagThreshold: 5n, + const cid = + existing && !/^0x0+$/.test(existing.details.metadataCid) + ? existing.details.metadataCid + : metadataCid + + festivalState.sessions = mergeSessions(festivalState.sessions, [ + { + address: sessionAddress, + details: { + metadataCid: cid, + creator, + poapContract: '0x0000000000000000000000000000000000000000' as `0x${string}`, + parentFestival: festivalState.festival?.address ?? '0x0000000000000000000000000000000000000000' as `0x${string}`, + startTime: 0n, + endTime: 0n, + cancelled: false, + registeredCount: 0n, + flagCount: 0n, + flagThreshold: 0n, + }, + metadata: null, + attendees: [], + poapTokenIds: [], }, - metadata: null, - attendees: [], - poapTokenIds: [], - }) + ]) +} + +/** + * Attach fetched metadata to a session. When expectedCid is given the write + * only lands if the entry still points at that cid, so a fetch that raced a + * newer update cannot put old content back. + */ +export function applySessionMetadata( + sessionAddress: `0x${string}`, + metadata: SubEventMetadata | null, + expectedCid?: `0x${string}`, +): void { + if (!metadata) return + const entry = festivalState.sessions.find( + (s) => s.address.toLowerCase() === sessionAddress.toLowerCase(), + ) + if (!entry) return + if (expectedCid && entry.details.metadataCid.toLowerCase() !== expectedCid.toLowerCase()) return + entry.metadata = metadata } // ── Persistent cache ─────────────────────────────────────────────────────── @@ -195,10 +223,20 @@ export function applySessionCreated( const CACHE_KEY_PREFIX = 'festivalState' +// Bump when CachedShape's structure changes incompatibly. A cache written by +// an older shape is dropped on read rather than half-hydrated into the new one. +const CACHE_VERSION = 1 + function cacheKey(festivalAddress: string, userH160: string | null): string { return `${CACHE_KEY_PREFIX}:${festivalAddress.toLowerCase()}:${userH160?.toLowerCase() ?? 'anon'}` } +// Pointer to the last user whose state was persisted, so a cold boot can +// hydrate the right cache slot before the wallet has connected. +function lastUserKey(festivalAddress: string): string { + return `${CACHE_KEY_PREFIX}:lastUser:${festivalAddress.toLowerCase()}` +} + const BIGINT_MARKER = '__bigint__:' function bigintReplacer(_key: string, value: unknown): unknown { @@ -214,26 +252,59 @@ function bigintReviver(_key: string, value: unknown): unknown { } interface CachedShape { + version: number festival: FestivalState['festival'] user: FestivalState['user'] sessions: FestivalState['sessions'] roles: FestivalState['roles'] } -/** Load cached state for the (festival, user) pair, if any, into the singleton. */ +/** + * Load cached state for the (festival, user) pair into the singleton. + * + * Cached data is **merged into** current state, not assigned over it: hydration + * is async (host storage bridge), so a watcher event or an early chain read may + * already have landed by the time the cache resolves. The monotonic merge keeps + * whichever side is fresher, so late hydration can never clobber live state. + */ export async function hydrateFromCache( festivalAddress: `0x${string}`, userAddress: `0x${string}` | null, + opts: { onlyBeforeBoot?: boolean } = {}, ): Promise { try { const raw = await getStorage().readJSON(cacheKey(festivalAddress, userAddress)) if (!raw || typeof raw !== 'string') return + // Checked after the await on purpose. The pre boot paint must stand down + // the moment a real load owns the state, even mid read. + if (opts.onlyBeforeBoot && (festivalState.loading || festivalState.loaded)) return const cached = JSON.parse(raw, bigintReviver) as CachedShape - if (!cached || typeof cached !== 'object') return - festivalState.festival = cached.festival - festivalState.user = cached.user - festivalState.sessions = cached.sessions - festivalState.roles = cached.roles + if (!cached || typeof cached !== 'object' || cached.version !== CACHE_VERSION) return + + if (cached.festival) { + const cur = festivalState.festival + festivalState.festival = cur + ? { ...cur, attendees: mergeAttendees(cached.festival.attendees, cur.attendees) } + : cached.festival + } + festivalState.sessions = mergeSessions(cached.sessions ?? [], festivalState.sessions) + + // Only touch the user slot when it belongs to the current identity, or + // when none is set yet. A slow hydrate can land after a boot for another + // account started and must not overwrite it. For the same identity we + // merge per field so a stale cache cannot regress fresh reads. + const slotUser = (userAddress ?? cached.user.address)?.toLowerCase() ?? null + const curUser = festivalState.user.address?.toLowerCase() ?? null + if (curUser === null || curUser === slotUser) { + const cur = festivalState.user + festivalState.user = { + address: userAddress ?? cached.user.address, + ticketTokenId: keepPositive(cached.user.ticketTokenId, cur.ticketTokenId), + festivalPoaps: mergePoaps(cached.user.festivalPoaps, cur.festivalPoaps), + sessionPoaps: mergePoaps(cached.user.sessionPoaps, cur.sessionPoaps), + roles: cur.roles.length ? cur.roles : cached.user.roles, + } + } } catch { // Cache miss / parse error. Silently fall back to chain reads. } @@ -246,6 +317,7 @@ export async function persistToCache( ): Promise { try { const blob: CachedShape = { + version: CACHE_VERSION, festival: festivalState.festival, user: festivalState.user, sessions: festivalState.sessions, @@ -253,7 +325,61 @@ export async function persistToCache( } const serialized = JSON.stringify(blob, bigintReplacer) await getStorage().writeJSON(cacheKey(festivalAddress, userAddress), serialized) + await getStorage().writeJSON(lastUserKey(festivalAddress), userAddress ?? 'anon') } catch (e) { console.warn('[festivalState] persistToCache failed:', e) } } + +/** + * Cache-first cold-boot paint, independent of the wallet. Reads the last-known + * user pointer and hydrates that slot, so the UI shows last-known state + * immediately even while (or if) wallet connection is still pending. Boot load + * re-hydrates and reconciles against the chain once the wallet resolves. + */ +export async function hydrateLastKnown(festivalAddress: `0x${string}`): Promise { + try { + const last = await getStorage().readJSON(lastUserKey(festivalAddress)) + if (festivalState.loading || festivalState.loaded) return + const user = last && last !== 'anon' ? (last as `0x${string}`) : null + await hydrateFromCache(festivalAddress, user, { onlyBeforeBoot: true }) + } catch { + // No pointer or read error. Nothing to paint, boot load fills state. + } +} + +// ── Persist-on-mutation ────────────────────────────────────────────────────── +// +// Boot load persists on success, but live mutations (watcher events, optimistic +// flips) used to reach the cache only on the next boot. A debounced deep watch +// closes that gap: any mutation to the singleton is written back, so a cold +// restart paints the last-known state regardless of which path produced it. + +let persistenceStarted = false +let persistTimer: ReturnType | null = null +const PERSIST_DEBOUNCE_MS = 1_000 + +/** + * Start write back persistence. Call once at app boot, repeat calls no op. + * The watcher lives in its own detached scope because callers can be layouts + * that unmount, and it has to outlive them since the once flag blocks a + * second start. + */ +export function startCachePersistence(): void { + if (persistenceStarted || typeof window === 'undefined') return + persistenceStarted = true + effectScope(true).run(() => { + watch( + () => [festivalState.festival, festivalState.sessions, festivalState.user, festivalState.roles], + () => { + if (!festivalState.festival?.address || persistTimer !== null) return + persistTimer = setTimeout(() => { + persistTimer = null + const addr = festivalState.festival?.address + if (addr) void persistToCache(addr, festivalState.user.address) + }, PERSIST_DEBOUNCE_MS) + }, + { deep: true }, + ) + }) +} diff --git a/packages/shared/cache/merge.test.ts b/packages/shared/cache/merge.test.ts new file mode 100644 index 0000000..c0c54ca --- /dev/null +++ b/packages/shared/cache/merge.test.ts @@ -0,0 +1,161 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + mergeAttendees, + mergeSession, + mergeSessions, + mergePoaps, + maxBig, + keepPositive, +} from './merge' +import type { AttendeeRow, SessionEntry, PoapEntry } from './festival-state' + +const A = '0x00000000000000000000000000000000000000aa' as `0x${string}` +const B = '0x00000000000000000000000000000000000000bb' as `0x${string}` +const ZERO = '0x0000000000000000000000000000000000000000' as `0x${string}` +const CREATOR = '0x00000000000000000000000000000000000000cc' as `0x${string}` + +function attendee(address: `0x${string}`, isCheckedIn: boolean): AttendeeRow { + return { address, isCheckedIn } +} + +function session(over: { + address: `0x${string}` + details?: Partial + metadata?: SessionEntry['metadata'] + attendees?: AttendeeRow[] + poapTokenIds?: bigint[] +}): SessionEntry { + return { + address: over.address, + details: { + metadataCid: ZERO as `0x${string}`, + creator: ZERO, + poapContract: ZERO, + parentFestival: ZERO, + startTime: 0n, + endTime: 0n, + cancelled: false, + registeredCount: 0n, + flagCount: 0n, + flagThreshold: 0n, + ...over.details, + } as SessionEntry['details'], + metadata: over.metadata ?? null, + attendees: over.attendees ?? [], + poapTokenIds: over.poapTokenIds ?? [], + } +} + +const realDetails = (over: Partial = {}) => ({ + creator: CREATOR, + startTime: 100n, + endTime: 200n, + ...over, +}) + +test('mergeAttendees: check-in latches true regardless of arrival order', () => { + const checkedIn = [attendee(A, true)] + const notYet = [attendee(A, false)] + assert.equal(mergeAttendees(checkedIn, notYet)[0]!.isCheckedIn, true) + assert.equal(mergeAttendees(notYet, checkedIn)[0]!.isCheckedIn, true) +}) + +test('mergeAttendees: unions addresses and never drops a known row', () => { + const merged = mergeAttendees([attendee(A, true)], [attendee(B, false)]) + assert.equal(merged.length, 2) + assert.equal(merged.find((x) => x.address === A)!.isCheckedIn, true) +}) + +test('mergeAttendees: address comparison is case-insensitive', () => { + const upper = { address: A.toUpperCase() as `0x${string}`, isCheckedIn: true } + const merged = mergeAttendees([upper], [attendee(A, false)]) + assert.equal(merged.length, 1) + assert.equal(merged[0]!.isCheckedIn, true) +}) + +test('mergeSession: real details beat a stub regardless of order', () => { + const stub = session({ address: A }) + const real = session({ address: A, details: realDetails() }) + assert.equal(mergeSession(stub, real).details.creator, CREATOR) + assert.equal(mergeSession(real, stub).details.creator, CREATOR) +}) + +test('mergeSession: a SessionCreated event stub (real creator, zeroed rest) cannot regress loaded details', () => { + // The watcher replays recent blocks on every resubscribe, so this exact + // merge happens on every app foreground for freshly created sessions. + const CID = ('0x' + 'cd'.repeat(32)) as `0x${string}` + const loaded = session({ + address: A, + details: realDetails({ + poapContract: B, + metadataCid: CID, + flagCount: 3n, + registeredCount: 4n, + flagThreshold: 3n, + }), + }) + const eventStub = session({ address: A, details: { creator: CREATOR, metadataCid: CID } }) + for (const merged of [mergeSession(loaded, eventStub), mergeSession(eventStub, loaded)]) { + assert.equal(merged.details.startTime, 100n) + assert.equal(merged.details.endTime, 200n) + assert.equal(merged.details.poapContract, B) + assert.equal(merged.details.flagCount, 3n) + assert.equal(merged.details.registeredCount, 4n) + assert.equal(merged.details.flagThreshold, 3n) + } +}) + +test('mergeSession: flagCount only advances; newest non-zero metadataCid wins', () => { + const CID_OLD = ('0x' + 'aa'.repeat(32)) as `0x${string}` + const CID_NEW = ('0x' + 'bb'.repeat(32)) as `0x${string}` + const prev = session({ address: A, details: realDetails({ flagCount: 4n, metadataCid: CID_OLD }) }) + const staleRead = session({ address: A, details: realDetails({ flagCount: 2n, metadataCid: CID_NEW }) }) + const merged = mergeSession(prev, staleRead) + assert.equal(merged.details.flagCount, 4n) + assert.equal(merged.details.metadataCid, CID_NEW) +}) + +test('mergeSession: cancelled latches and registeredCount only advances', () => { + const cancelled = session({ address: A, details: realDetails({ cancelled: true, registeredCount: 3n }) }) + const fresh = session({ address: A, details: realDetails({ cancelled: false, registeredCount: 1n }) }) + const merged = mergeSession(cancelled, fresh) + assert.equal(merged.details.cancelled, true) + assert.equal(merged.details.registeredCount, 3n) +}) + +test('mergeSession: non-null metadata is preferred over null either way', () => { + const withMeta = session({ address: A, details: realDetails(), metadata: { name: 'X' } as any }) + const noMeta = session({ address: A, details: realDetails() }) + assert.deepEqual(mergeSession(noMeta, withMeta).metadata, { name: 'X' }) + assert.deepEqual(mergeSession(withMeta, noMeta).metadata, { name: 'X' }) +}) + +test('mergeSession: poap token ids union without duplicates', () => { + const a = session({ address: A, details: realDetails(), poapTokenIds: [1n, 2n] }) + const b = session({ address: A, details: realDetails(), poapTokenIds: [2n, 3n] }) + assert.deepEqual(mergeSession(a, b).poapTokenIds.sort(), [1n, 2n, 3n]) +}) + +test('mergeSessions: unions by address and upgrades stubs in place', () => { + const prev = [session({ address: A }), session({ address: B, details: realDetails() })] + const incoming = [session({ address: A, details: realDetails() })] + const merged = mergeSessions(prev, incoming) + assert.equal(merged.length, 2) + assert.equal(merged.find((s) => s.address === A)!.details.creator, CREATOR) +}) + +test('mergePoaps: union by (contract, tokenId), incoming wins on conflict', () => { + const prev: PoapEntry[] = [{ poapContract: A, tokenId: 1n, data: { v: 'old' } as any }] + const incoming: PoapEntry[] = [{ poapContract: A, tokenId: 1n, data: { v: 'new' } as any }] + const merged = mergePoaps(prev, incoming) + assert.equal(merged.length, 1) + assert.equal((merged[0]!.data as any).v, 'new') +}) + +test('maxBig / keepPositive', () => { + assert.equal(maxBig(3n, 5n), 5n) + assert.equal(maxBig(5n, 3n), 5n) + assert.equal(keepPositive(7n, 0n), 7n) + assert.equal(keepPositive(0n, 7n), 7n) +}) diff --git a/packages/shared/cache/merge.ts b/packages/shared/cache/merge.ts new file mode 100644 index 0000000..7894632 --- /dev/null +++ b/packages/shared/cache/merge.ts @@ -0,0 +1,122 @@ +import type { AttendeeRow, SessionEntry, PoapEntry } from './festival-state' + +/** + * Pure, order-independent merge helpers for the confirmed state tier. + * + * Every field is merged **upgrade-only**: a monotonic positive (a check-in, a + * registration, a higher count, real details over a stub) is never regressed. + * This makes a lagging or out-of-order chain read unable to clobber fresher + * state — the root cause behind the onboarding check-in flipping back to + * false. The only accepted cost is that a value reorged out on chain stays + * "true" locally until the contract emits a contradicting state, which for + * monotonic festival actions (check-in, registration, POAP mint, cancellation) + * cannot happen. + */ + +const ZERO_ADDR = '0x0000000000000000000000000000000000000000' + +function lc(addr: string): string { + return addr.toLowerCase() +} + +/** Take the incoming value unless it is a stub zero. Newest real value wins. */ +function nonZeroHex(prev: T, incoming: T): T { + return /^0x0+$/.test(incoming) ? prev : incoming +} + +export function maxBig(a: bigint, b: bigint): bigint { + return a > b ? a : b +} + +/** Once the ticket id is positive, keep it positive; a fresh positive wins. */ +export function keepPositive(prev: bigint, incoming: bigint): bigint { + return incoming > 0n ? incoming : prev +} + +function unionBig(a: readonly bigint[], b: readonly bigint[]): bigint[] { + const seen = new Set(a) + const out = [...a] + for (const x of b) { + if (!seen.has(x)) { + seen.add(x) + out.push(x) + } + } + return out +} + +/** Union by lowercased address; `isCheckedIn` latches true; never drops a row. */ +export function mergeAttendees( + prev: readonly AttendeeRow[], + incoming: readonly AttendeeRow[], +): AttendeeRow[] { + const byAddr = new Map() + for (const a of prev) byAddr.set(lc(a.address), { ...a }) + for (const a of incoming) { + const k = lc(a.address) + const existing = byAddr.get(k) + if (existing) existing.isCheckedIn = existing.isCheckedIn || a.isCheckedIn + else byAddr.set(k, { ...a }) + } + return [...byAddr.values()] +} + +/** Union by (contract, tokenId); incoming wins on conflict (fresher POAP data). */ +export function mergePoaps( + prev: readonly PoapEntry[], + incoming: readonly PoapEntry[], +): PoapEntry[] { + const byKey = new Map() + for (const p of prev) byKey.set(`${lc(p.poapContract)}:${p.tokenId}`, p) + for (const p of incoming) byKey.set(`${lc(p.poapContract)}:${p.tokenId}`, p) + return [...byKey.values()] +} + +/** + * Merge field by field so arrival order never matters. The watcher replays + * recent blocks whenever it resubscribes, so an event stub with zeroed times + * can land after the full read. Zeros never beat known values and the + * monotonic fields only move forward. + */ +export function mergeSession( + prev: SessionEntry | undefined, + incoming: SessionEntry, +): SessionEntry { + if (!prev) return incoming + + const a = prev.details + const b = incoming.details + + return { + address: prev.address, + details: { + metadataCid: nonZeroHex(a.metadataCid, b.metadataCid), + creator: nonZeroHex(a.creator, b.creator), + poapContract: nonZeroHex(a.poapContract, b.poapContract), + parentFestival: nonZeroHex(a.parentFestival, b.parentFestival), + startTime: keepPositive(a.startTime, b.startTime), + endTime: keepPositive(a.endTime, b.endTime), + cancelled: a.cancelled || b.cancelled, + registeredCount: maxBig(a.registeredCount, b.registeredCount), + flagCount: maxBig(a.flagCount, b.flagCount), + flagThreshold: keepPositive(a.flagThreshold, b.flagThreshold), + }, + metadata: incoming.metadata ?? prev.metadata, + attendees: mergeAttendees(prev.attendees, incoming.attendees), + poapTokenIds: unionBig(prev.poapTokenIds, incoming.poapTokenIds), + } +} + +/** Union by address; each address merged via {@link mergeSession}. */ +export function mergeSessions( + prev: readonly SessionEntry[], + incoming: readonly SessionEntry[], +): SessionEntry[] { + const byAddr = new Map() + for (const s of prev) byAddr.set(lc(s.address), s) + for (const s of incoming) { + const k = lc(s.address) + byAddr.set(k, mergeSession(byAddr.get(k), s)) + } + return [...byAddr.values()] +} diff --git a/packages/shared/cache/pending.test.ts b/packages/shared/cache/pending.test.ts new file mode 100644 index 0000000..8208616 --- /dev/null +++ b/packages/shared/cache/pending.test.ts @@ -0,0 +1,183 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { nextTick } from 'vue' +import { + addPending, + dropPending, + hasPending, + pendingSessions, + pendingCheckins, + sessionScopedId, + draftSessionEntry, + startPendingReconcile, +} from './pending' +import { + festivalState, + resetFestivalState, + applyRegistered, + type SessionEntry, +} from './festival-state' + +// startPendingReconcile is window-gated (app-runtime guard); node:test runs +// this file in its own process, so the stub is contained. +;(globalThis as any).window = (globalThis as any).window ?? {} +startPendingReconcile() + +const USER = '0x00000000000000000000000000000000000000Aa' +const SESSION_ADDR = '0x00000000000000000000000000000000000000bb' as `0x${string}` +const CID = '0x' + 'ab'.repeat(32) as `0x${string}` +const ZERO = '0x0000000000000000000000000000000000000000' as `0x${string}` + +function seedFestival(attendees: { address: `0x${string}`; isCheckedIn: boolean }[]) { + festivalState.festival = { + address: ZERO, + details: { + metadataCid: ('0x' + '00'.repeat(32)) as `0x${string}`, + creator: ZERO, + festivalPoapContract: ZERO, + sessionPoapContract: ZERO, + startTime: 0n, + endTime: 0n, + sessionsEnabled: true, + capacity: 0, + cancelled: false, + registeredCount: BigInt(attendees.length), + }, + metadata: null, + attendees, + } +} + +function session(over: Partial<{ cancelled: boolean; metadataCid: `0x${string}` }>): SessionEntry { + return { + address: SESSION_ADDR, + details: { + metadataCid: over.metadataCid ?? (('0x' + '00'.repeat(32)) as `0x${string}`), + creator: ZERO, + poapContract: ZERO, + parentFestival: ZERO, + startTime: 0n, + endTime: 0n, + cancelled: over.cancelled ?? false, + registeredCount: 0n, + flagCount: 0n, + flagThreshold: 5n, + }, + metadata: null, + attendees: [], + poapTokenIds: [], + } +} + +test('applyRegistered is idempotent: replayed events cannot inflate registeredCount', () => { + resetFestivalState() + seedFestival([]) + applyRegistered(USER as `0x${string}`) + applyRegistered(USER as `0x${string}`) + assert.equal(festivalState.festival!.details.registeredCount, 1n) + assert.equal(festivalState.festival!.attendees.length, 1) + // Row already known from a read that counted it → no double count. + seedFestival([{ address: USER as `0x${string}`, isCheckedIn: false }]) + festivalState.festival!.details.registeredCount = 1n + applyRegistered(USER as `0x${string}`) + assert.equal(festivalState.festival!.details.registeredCount, 1n) +}) + +test('add / has / drop, id case-insensitive', () => { + resetFestivalState() + addPending('register', USER) + assert.equal(hasPending('register', USER.toLowerCase()), true) + assert.equal(hasPending('register', USER.toUpperCase().replace('0X', '0x')), true) + dropPending('register', USER) + assert.equal(hasPending('register', USER), false) +}) + +test('pendingSessions returns only entries carrying a draft session', () => { + resetFestivalState() + addPending('register', USER) + addPending('session', CID, session({ metadataCid: CID })) + const drafts = pendingSessions() + assert.equal(drafts.length, 1) + assert.equal(drafts[0]!.address, SESSION_ADDR) + dropPending('register', USER) + dropPending('session', CID) +}) + +test('register promotes (GC) once the attendee row confirms', async () => { + resetFestivalState() + await nextTick() + addPending('register', USER) + seedFestival([]) + await nextTick() + assert.equal(hasPending('register', USER), true, 'no row yet — pending survives') + seedFestival([{ address: USER as `0x${string}`, isCheckedIn: false }]) + await nextTick() + assert.equal(hasPending('register', USER), false, 'row confirmed — promoted') +}) + +test('checkin survives a registered-only row, promotes on isCheckedIn', async () => { + resetFestivalState() + await nextTick() + addPending('checkin', USER) + seedFestival([{ address: USER as `0x${string}`, isCheckedIn: false }]) + await nextTick() + assert.equal(hasPending('checkin', USER), true) + seedFestival([{ address: USER as `0x${string}`, isCheckedIn: true }]) + await nextTick() + assert.equal(hasPending('checkin', USER), false) +}) + +test('session promotes when a confirmed session carries the metadata CID', async () => { + resetFestivalState() + await nextTick() + addPending('session', CID, session({ metadataCid: CID })) + festivalState.sessions = [session({ metadataCid: CID })] + await nextTick() + assert.equal(hasPending('session', CID), false) +}) + +test('a cancelled session with the same CID does not promote a recreate draft', async () => { + resetFestivalState() + await nextTick() + addPending('session', CID, session({ metadataCid: CID })) + festivalState.sessions = [session({ metadataCid: CID, cancelled: true })] + await nextTick() + assert.equal(hasPending('session', CID), true, 'cancelled predecessor must not swallow the draft') + dropPending('session', CID) +}) + +test('session-scoped checkin promotes off the session attendees, not the festival', async () => { + resetFestivalState() + await nextTick() + const id = sessionScopedId(USER, SESSION_ADDR) + addPending('checkin', id) + // Festival-level check-in must NOT promote a session-scoped entry. + seedFestival([{ address: USER as `0x${string}`, isCheckedIn: true }]) + await nextTick() + assert.equal(hasPending('checkin', id), true) + assert.deepEqual(pendingCheckins(), [], 'session-scoped entries are not festival check-ins') + const s = session({}) + s.attendees = [{ address: USER as `0x${string}`, isCheckedIn: true }] + festivalState.sessions = [s] + await nextTick() + assert.equal(hasPending('checkin', id), false) +}) + +test('draftSessionEntry derives a stable placeholder address from the CID', () => { + const draft = draftSessionEntry(CID, 1n, 2n, USER as `0x${string}`, null) + assert.equal(draft.address, `0x${CID.slice(2, 42)}`) + assert.equal(draft.details.metadataCid, CID) + assert.equal(draft.details.cancelled, false) +}) + +test('cancelSession promotes when the confirmed session flips cancelled', async () => { + resetFestivalState() + await nextTick() + addPending('cancelSession', SESSION_ADDR) + festivalState.sessions = [session({ cancelled: false })] + await nextTick() + assert.equal(hasPending('cancelSession', SESSION_ADDR), true) + festivalState.sessions = [session({ cancelled: true })] + await nextTick() + assert.equal(hasPending('cancelSession', SESSION_ADDR), false) +}) diff --git a/packages/shared/cache/pending.ts b/packages/shared/cache/pending.ts new file mode 100644 index 0000000..440d9b6 --- /dev/null +++ b/packages/shared/cache/pending.ts @@ -0,0 +1,161 @@ +import { reactive, watch, effectScope } from 'vue' +import type { SubEventMetadata } from '../metadata/schemas' +import { festivalState, type SessionEntry } from './festival-state' + +/** + * Pending overlay — the per-actor optimistic tier. + * + * When this device submits a transaction, we add a pending entry so the UI + * reflects the action instantly. The entry is the ONLY downgrade path that the + * UI honours: it's dropped when the action fails on this device, or promoted + * away once the confirmed tier (chain reads / best-block events) catches up. + * A lagging or out-of-order read can never revert it — that's the confirmed + * tier's monotonic merge job. Pending lives only in memory: a mid-tx app kill + * leaves no phantom state, because the chain event (success-gated) is what + * makes it permanent. + */ + +export type PendingKind = 'register' | 'checkin' | 'session' | 'cancelSession' + +interface PendingEntry { + kind: PendingKind + /** + * Lowercased id: an attendee address ('register'/'checkin'), an + * `@` pair for session-scoped check-ins, a session + * metadata CID ('session'), or a session address ('cancelSession'). + */ + id: string + /** Draft session to render immediately (only for kind === 'session'). */ + session?: SessionEntry +} + +/** Id for a check-in scoped to a session contract rather than the festival. */ +export function sessionScopedId(attendee: string, sessionAddress: string): string { + return `${attendee.toLowerCase()}@${sessionAddress.toLowerCase()}` +} + +const pending = reactive(new Map()) + +function keyOf(kind: PendingKind, id: string): string { + return `${kind}:${id.toLowerCase()}` +} + +export function addPending(kind: PendingKind, id: string, session?: SessionEntry): void { + pending.set(keyOf(kind, id), { kind, id: id.toLowerCase(), session }) +} + +export function dropPending(kind: PendingKind, id: string): void { + pending.delete(keyOf(kind, id)) +} + +export function hasPending(kind: PendingKind, id: string): boolean { + return pending.has(keyOf(kind, id)) +} + +/** Draft sessions to splice into the session list while their tx is in flight. */ +export function pendingSessions(): SessionEntry[] { + const out: SessionEntry[] = [] + for (const e of pending.values()) if (e.session) out.push(e.session) + return out +} + +/** Festival-scoped check-ins in flight (lowercased attendee addresses). */ +export function pendingCheckins(): string[] { + const out: string[] = [] + for (const e of pending.values()) { + if (e.kind === 'checkin' && !e.id.includes('@')) out.push(e.id) + } + return out +} + +const ZERO_ADDR = '0x0000000000000000000000000000000000000000' as `0x${string}` + +/** + * Draft entry for a session whose create tx is in flight. The address is a + * placeholder derived from the metadata CID (unique + stable for list keys); + * the confirmed entry carries the real address and supersedes the draft. + */ +export function draftSessionEntry( + metadataCid: `0x${string}`, + startTime: bigint, + endTime: bigint, + creator: `0x${string}`, + metadata: SubEventMetadata | null = null, +): SessionEntry { + return { + address: `0x${metadataCid.slice(2, 42)}` as `0x${string}`, + details: { + metadataCid, + creator, + poapContract: ZERO_ADDR, + parentFestival: festivalState.festival?.address ?? ZERO_ADDR, + startTime, + endTime, + cancelled: false, + registeredCount: 0n, + flagCount: 0n, + flagThreshold: 0n, + }, + metadata, + attendees: [], + poapTokenIds: [], + } +} + +/** True once the confirmed tier reflects a pending entry (so it can be dropped). */ +function isConfirmed(entry: PendingEntry): boolean { + const fest = festivalState.festival + switch (entry.kind) { + case 'register': + return Boolean(fest?.attendees.some((a) => a.address.toLowerCase() === entry.id)) + case 'checkin': { + const [attendee, sessionAddr] = entry.id.split('@') + if (sessionAddr) { + const session = festivalState.sessions.find( + (s) => s.address.toLowerCase() === sessionAddr, + ) + return Boolean( + session?.attendees.find((a) => a.address.toLowerCase() === attendee)?.isCheckedIn, + ) + } + return Boolean( + fest?.attendees.find((a) => a.address.toLowerCase() === attendee)?.isCheckedIn, + ) + } + case 'session': + // Skip cancelled sessions. Recreating one with the same metadata gives + // the same CID and the old entry must not swallow the new draft. + return festivalState.sessions.some( + (s) => !s.details.cancelled && s.details.metadataCid.toLowerCase() === entry.id, + ) + case 'cancelSession': + return Boolean( + festivalState.sessions.find((s) => s.address.toLowerCase() === entry.id)?.details + .cancelled, + ) + } +} + +let reconcileStarted = false + +/** + * Promotion/GC: drop any pending entry the confirmed tier now covers. Idempotent; + * call once at app boot. Driven by a deep watch on festivalState so promotion + * happens the instant a chain read or event lands. + */ +export function startPendingReconcile(): void { + if (reconcileStarted || typeof window === 'undefined') return + reconcileStarted = true + // Own detached scope so the GC survives the calling layout unmounting. + effectScope(true).run(() => { + watch( + () => [festivalState.festival, festivalState.sessions], + () => { + for (const [k, entry] of pending) { + if (isConfirmed(entry)) pending.delete(k) + } + }, + { deep: true }, + ) + }) +} diff --git a/packages/shared/cache/persistent.ts b/packages/shared/cache/persistent.ts new file mode 100644 index 0000000..feba4b4 --- /dev/null +++ b/packages/shared/cache/persistent.ts @@ -0,0 +1,69 @@ +import { ref, watch, type Ref } from 'vue' +import { getStorage } from './storage' + +/** + * A ref backed by BOTH layers: + * - `window.localStorage` — synchronous, read at creation for instant paint + * (unchanged cold-start UX). + * - the host storage bridge (`getStorage()`) — async, durable across mobile + * WebView eviction / process death, which plain localStorage is not. + * + * On creation it inits synchronously from localStorage, then asynchronously + * adopts the host value if present (host is authoritative); if the host slot is + * empty but localStorage has a value, it migrates that value up once. Every + * change writes through to both. Mirrors the localStorage-fast + durable-fallback + * pattern useFestivalPass already uses for the activation flag. + */ +export function usePersistentRef(key: string, initial: T): Ref { + const local = readLocalSync(key) + const state = ref(local ?? initial) as Ref + + // A mutation that lands before the async host read resolves must win over + // the (older) host value — write-through is already syncing it up. + let dirty = false + + void (async () => { + try { + const hostVal = await getStorage().readJSON(key) + if (dirty) return + if (hostVal != null) { + state.value = hostVal + } else if (local != null) { + await getStorage().writeJSON(key, local) + } + } catch { + // Host bridge unavailable (standalone, or pre-host boot). localStorage covers us. + } + })() + + watch( + state, + (v) => { + dirty = true + writeLocalSync(key, v) + void getStorage().writeJSON(key, v) + }, + { deep: true }, + ) + + return state +} + +function readLocalSync(key: string): T | null { + if (typeof window === 'undefined') return null + try { + const raw = window.localStorage.getItem(key) + return raw ? (JSON.parse(raw) as T) : null + } catch { + return null + } +} + +function writeLocalSync(key: string, value: unknown): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Quota / private mode. Host storage still gets the durable copy. + } +} diff --git a/packages/shared/cache/scope-survival.test.ts b/packages/shared/cache/scope-survival.test.ts new file mode 100644 index 0000000..89374af --- /dev/null +++ b/packages/shared/cache/scope-survival.test.ts @@ -0,0 +1,115 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { effectScope, nextTick } from 'vue' +import { + festivalState, + resetFestivalState, + hydrateFromCache, + persistToCache, + startCachePersistence, +} from './festival-state' +import { addPending, hasPending, startPendingReconcile } from './pending' + +// Self referential window so isInHost() stays false and getStorage() picks +// the localStorage backend, which we shim with a plain map. +const w: any = {} +w.top = w +;(globalThis as any).window = w +const store = new Map() +;(globalThis as any).localStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), +} + +const F = '0x00000000000000000000000000000000000000f1' as `0x${string}` +const A = '0x00000000000000000000000000000000000000aa' as `0x${string}` +const B = '0x00000000000000000000000000000000000000bb' as `0x${string}` + +function seedFestival() { + festivalState.festival = { + address: F, + details: { + metadataCid: ('0x' + '00'.repeat(32)) as `0x${string}`, + creator: A, + festivalPoapContract: A, + sessionPoapContract: A, + startTime: 0n, + endTime: 0n, + sessionsEnabled: true, + capacity: 0, + cancelled: false, + registeredCount: 0n, + }, + metadata: null, + attendees: [], + } +} + +test('pending GC survives the arming scope being stopped', async () => { + resetFestivalState() + const scope = effectScope() + scope.run(() => startPendingReconcile()) + scope.stop() + + addPending('register', A) + seedFestival() + festivalState.festival!.attendees = [{ address: A, isCheckedIn: false }] + await nextTick() + assert.equal(hasPending('register', A), false, 'GC must outlive the caller scope') +}) + +test('cache persistence survives the arming scope being stopped', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }) + resetFestivalState() + const scope = effectScope() + scope.run(() => startCachePersistence()) + scope.stop() + + seedFestival() + await nextTick() + t.mock.timers.tick(1100) + for (let i = 0; i < 5; i++) await Promise.resolve() + assert.ok( + store.has(`festivalState:${F}:anon`), + 'debounced persist must outlive the caller scope', + ) + t.mock.timers.reset() +}) + +test('hydrate skips the user slot when it belongs to another identity', async () => { + resetFestivalState() + seedFestival() + festivalState.user.address = A + festivalState.user.ticketTokenId = 5n + await persistToCache(F, A) + + resetFestivalState() + festivalState.user.address = B + await hydrateFromCache(F, A) + assert.equal(festivalState.user.address, B) + assert.equal(festivalState.user.ticketTokenId, 0n) +}) + +test('hydrate fills the user slot when no identity is set yet', async () => { + resetFestivalState() + await hydrateFromCache(F, A) + assert.equal(festivalState.user.address?.toLowerCase(), A) + assert.equal(festivalState.user.ticketTokenId, 5n) +}) + +test('same identity hydrate merges per field and cannot regress fresh reads', async () => { + resetFestivalState() + festivalState.user.address = A + festivalState.user.ticketTokenId = 7n + await hydrateFromCache(F, A) + assert.equal(festivalState.user.ticketTokenId, 7n, 'cached 5n must not beat fresh 7n') +}) + +test('the pre boot paint stands down once a load owns the state', async () => { + resetFestivalState() + festivalState.loading = true + await hydrateFromCache(F, A, { onlyBeforeBoot: true }) + assert.equal(festivalState.user.address, null) + assert.equal(festivalState.user.ticketTokenId, 0n) +}) diff --git a/packages/shared/cache/useFestivalWatcher.ts b/packages/shared/cache/useFestivalWatcher.ts index f2ecd95..99fff04 100644 --- a/packages/shared/cache/useFestivalWatcher.ts +++ b/packages/shared/cache/useFestivalWatcher.ts @@ -1,14 +1,18 @@ import { onUnmounted, getCurrentInstance, watch, type Ref, type WatchStopHandle } from 'vue' -import type { FestivalMetadata } from '../metadata/schemas' +import type { FestivalMetadata, SubEventMetadata } from '../metadata/schemas' +import { hydrateSubEventMetadata } from '../metadata/schemas' import { useBulletinStorage } from '../metadata/bulletin' +import { isNonZeroCid } from '../contracts/festival-reads' import { watchFestivalEvents } from './event-watcher' import { + festivalState, applyMetadataUpdated, applyRegistered, applyCheckedIn, applyCapacityUpdated, applyCancelled, applySessionCreated, + applySessionMetadata, } from './festival-state' export interface FestivalWatcherOptions { @@ -62,6 +66,19 @@ export function useFestivalWatcher( } } + // Tear down the current chainHead follow and open a fresh one. The host can + // silently pause the WebSocket while backgrounded; on resume the existing + // follow may be dead with no error emitted, so the visibility handler calls + // this after reconciling to guarantee live updates resume. + function restart() { + if (disposed) return + if (active) { + active.unsubscribe() + active = null + } + active = buildHandlers() + } + function buildHandlers() { return watchFestivalEvents(festivalAddress, { onMetadataUpdated: async (newCid) => { @@ -87,12 +104,27 @@ export function useFestivalWatcher( applyCheckedIn(attendee as `0x${string}`) }, - onSessionCreated: (sessionAddr, creator, metadataCid) => { + onSessionCreated: async (sessionAddr, creator, metadataCid) => { applySessionCreated( sessionAddr as `0x${string}`, creator as `0x${string}`, metadataCid, ) + // Fetch the metadata so the card shows a title right away. Skip when + // the entry already moved past the creation cid, and pass the cid so + // a fetch that raced a newer update cannot apply old content. + const entryCid = festivalState.sessions + .find((s) => s.address.toLowerCase() === sessionAddr.toLowerCase()) + ?.details.metadataCid.toLowerCase() + if (isNonZeroCid(metadataCid) && entryCid === metadataCid.toLowerCase()) { + try { + const { retrievePlaintext } = useBulletinStorage() + const raw = await retrievePlaintext(metadataCid) + applySessionMetadata(sessionAddr as `0x${string}`, hydrateSubEventMetadata(raw), metadataCid) + } catch (e) { + console.warn('[FestivalWatcher] session metadata fetch failed:', e) + } + } }, onCapacityUpdated: (newCapacity) => { @@ -129,4 +161,6 @@ export function useFestivalWatcher( // Reserved for future use: drift detection driven by reconcile diffs. void options.onDriftDetected + + return { restart } }