fix(events): key the events store by addressable coordinate (#121) #125

Merged
padreug merged 1 commit from fix/events-store-coordinate-key into dev 2026-06-18 12:03:10 +00:00
2 changed files with 182 additions and 10 deletions

View file

@ -0,0 +1,120 @@
import { beforeEach, describe, it, expect } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useEventsStore, eventCoordinate, eventKind } from './events'
import type { Event } from '../types/event'
// Minimal Event factory — only the fields the store touches matter; the
// rest are filled with inert defaults and cast to the full type.
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 'd-tag-1',
nostrEventId: 'nostr-id',
type: 'time',
organizer: { pubkey: 'pubkey-alice' },
title: 'Test Event',
description: '',
startDate: new Date('2026-07-01T18:00:00Z'),
tags: [],
isPrivate: false,
createdAt: new Date('2026-06-01T00:00:00Z'),
...overrides,
} as Event
}
describe('eventKind / eventCoordinate', () => {
it('maps date events to 31922 and time events to 31923', () => {
expect(eventKind(makeEvent({ type: 'date' }))).toBe(31922)
expect(eventKind(makeEvent({ type: 'time' }))).toBe(31923)
})
it('builds kind:pubkey:d-tag coordinates', () => {
const e = makeEvent({ type: 'time', id: 'abc', organizer: { pubkey: 'pk' } })
expect(eventCoordinate(e)).toBe('31923:pk:abc')
})
it('distinguishes same d-tag across authors', () => {
const a = makeEvent({ id: 'same', organizer: { pubkey: 'alice' } })
const b = makeEvent({ id: 'same', organizer: { pubkey: 'mallory' } })
expect(eventCoordinate(a)).not.toBe(eventCoordinate(b))
})
})
describe('useEventsStore.upsertEvent', () => {
beforeEach(() => setActivePinia(createPinia()))
it('keeps the newer version of the same coordinate', () => {
const store = useEventsStore()
const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' })
const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' })
store.upsertEvent(older)
store.upsertEvent(newer)
expect(store.events).toHaveLength(1)
expect(store.getEventById('d-tag-1')?.title).toBe('new')
})
it('ignores an older version of the same coordinate', () => {
const store = useEventsStore()
const newer = makeEvent({ createdAt: new Date('2026-06-02T00:00:00Z'), title: 'new' })
const older = makeEvent({ createdAt: new Date('2026-06-01T00:00:00Z'), title: 'old' })
store.upsertEvent(newer)
store.upsertEvent(older)
expect(store.events).toHaveLength(1)
expect(store.getEventById('d-tag-1')?.title).toBe('new')
})
it('does NOT let a different author overwrite a same-d-tag event (cross-author hijack)', () => {
const store = useEventsStore()
const legit = makeEvent({
id: 'concert',
organizer: { pubkey: 'alice' },
title: 'Alice concert',
createdAt: new Date('2026-06-01T00:00:00Z'),
})
// Mallory republishes the same d-tag with a newer created_at — must
// NOT clobber Alice's event; both are kept under their own coordinate.
const impostor = makeEvent({
id: 'concert',
organizer: { pubkey: 'mallory' },
title: 'Mallory hijack',
createdAt: new Date('2026-06-10T00:00:00Z'),
})
store.upsertEvent(legit)
store.upsertEvent(impostor)
expect(store.events).toHaveLength(2)
expect(store.getByCoordinate('31923:alice:concert')?.title).toBe('Alice concert')
expect(store.getByCoordinate('31923:mallory:concert')?.title).toBe('Mallory hijack')
})
})
describe('useEventsStore lookups & removal', () => {
beforeEach(() => setActivePinia(createPinia()))
it('getEventById resolves by d-tag (route identifier)', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'party', organizer: { pubkey: 'alice' } }))
expect(store.getEventById('party')?.id).toBe('party')
expect(store.getEventById('missing')).toBeUndefined()
})
it('getEventById returns the newest when a d-tag is shared across authors', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' }, title: 'older', createdAt: new Date('2026-06-01') }))
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' }, title: 'newer', createdAt: new Date('2026-06-05') }))
expect(store.getEventById('x')?.title).toBe('newer')
})
it('removeEvent deletes every coordinate sharing the d-tag', () => {
const store = useEventsStore()
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'a' } }))
store.upsertEvent(makeEvent({ id: 'x', organizer: { pubkey: 'b' } }))
expect(store.events).toHaveLength(2)
store.removeEvent('x')
expect(store.events).toHaveLength(0)
})
})

View file

@ -3,12 +3,37 @@ import { ref, computed } from 'vue'
import type { Event } from '../types/event' import type { Event } from '../types/event'
import type { TicketedEvent } from '../types/ticket' import type { TicketedEvent } from '../types/ticket'
/** NIP-52 calendar event kinds. Date-based = 31922, time-based = 31923. */
export const EVENT_KIND_DATE = 31922
export const EVENT_KIND_TIME = 31923
/** The NIP-52 kind for an event, derived from its date/time type. */
export function eventKind(event: Pick<Event, 'type'>): number {
return event.type === 'date' ? EVENT_KIND_DATE : EVENT_KIND_TIME
}
/**
* Addressable-event coordinate `kind:pubkey:d-tag` (NIP-01 `a` tag form).
*
* NIP-52 calendar events are *addressable* (parameterized-replaceable):
* their d-tag is scoped to the **author**, so the replacement key MUST
* include the pubkey. Keying by the bare d-tag alone lets a different
* author publishing the same d-tag overwrite a legit event in the store.
* This mirrors NDK's `event.coordinate()` and welshman's `eventsByAddress`.
*/
export function eventCoordinate(
event: Pick<Event, 'type' | 'organizer' | 'id'>,
): string {
return `${eventKind(event)}:${event.organizer.pubkey}:${event.id}`
}
/** /**
* Pinia store for cached events from Nostr relays. * Pinia store for cached events from Nostr relays.
* Handles deduplication by NIP-52 addressable event key (kind:pubkey:d-tag). * Deduplicates by NIP-52 addressable coordinate (kind:pubkey:d-tag).
*/ */
export const useEventsStore = defineStore('events', () => { export const useEventsStore = defineStore('events', () => {
// State // State — keyed by addressable coordinate, NOT bare d-tag, so two
// authors using the same d-tag are stored independently.
const eventsMap = ref<Map<string, Event>>(new Map()) const eventsMap = ref<Map<string, Event>>(new Map())
const isLoading = ref(false) const isLoading = ref(false)
const lastUpdated = ref<Date | null>(null) const lastUpdated = ref<Date | null>(null)
@ -43,14 +68,19 @@ export const useEventsStore = defineStore('events', () => {
/** /**
* Add or update an event in the store. * Add or update an event in the store.
* Deduplicates by id (d-tag). Newer events replace older ones. *
* Deduplicates by addressable coordinate (kind:pubkey:d-tag). A newer
* version (by `created_at`) replaces an older one *for the same
* coordinate only* a same-d-tag event from a different author lands
* under its own coordinate and never clobbers another author's event.
*/ */
function upsertEvent(event: Event) { function upsertEvent(event: Event) {
const existing = eventsMap.value.get(event.id) const key = eventCoordinate(event)
const existing = eventsMap.value.get(key)
// Only update if this is a newer version // Only update if this is a newer version of the same coordinate.
if (!existing || event.createdAt >= existing.createdAt) { if (!existing || event.createdAt >= existing.createdAt) {
eventsMap.value.set(event.id, event) eventsMap.value.set(key, event)
lastUpdated.value = new Date() lastUpdated.value = new Date()
} }
} }
@ -65,10 +95,13 @@ export const useEventsStore = defineStore('events', () => {
} }
/** /**
* Remove an event from the store. * Remove an event by its d-tag. Deletes every stored coordinate whose
* d-tag matches (normally one our calendar events are single-publisher).
*/ */
function removeEvent(id: string) { function removeEvent(id: string) {
eventsMap.value.delete(id) for (const [key, event] of eventsMap.value) {
if (event.id === id) eventsMap.value.delete(key)
}
} }
/** /**
@ -80,10 +113,28 @@ export const useEventsStore = defineStore('events', () => {
} }
/** /**
* Get a single event by its id (d-tag). * Get a single event by its full addressable coordinate (kind:pubkey:d-tag).
* The precise, unambiguous lookup.
*/
function getByCoordinate(coordinate: string): Event | undefined {
return eventsMap.value.get(coordinate)
}
/**
* Get a single event by its d-tag (the route identifier).
*
* Calendar events in this app are single-publisher, so a d-tag resolves
* to one event in practice. If multiple authors ever share a d-tag, the
* newest (by `created_at`) wins deterministic rather than first-seen.
* Use {@link getByCoordinate} when the author is known.
*/ */
function getEventById(id: string): Event | undefined { function getEventById(id: string): Event | undefined {
return eventsMap.value.get(id) let match: Event | undefined
for (const event of eventsMap.value.values()) {
if (event.id !== id) continue
if (!match || event.createdAt >= match.createdAt) match = event
}
return match
} }
return { return {
@ -104,6 +155,7 @@ export const useEventsStore = defineStore('events', () => {
upsertEvents, upsertEvents,
removeEvent, removeEvent,
clearAll, clearAll,
getByCoordinate,
getEventById, getEventById,
} }
}) })