fix(events): key the events store by addressable coordinate (#121)
NIP-52 calendar events (kinds 31922/31923) are addressable: their d-tag
is author-scoped, so the replacement key is kind:pubkey:d-tag, not the
bare d-tag. The store keyed `eventsMap` by `event.id` (d-tag) and
replaced on newer `created_at` ignoring pubkey, so a different author
republishing the same d-tag could overwrite a legit event in the store
(cross-author hijack). NDK (`event.coordinate()`) and welshman
(`eventsByAddress`) both key addressable events by the full coordinate.
- Key `eventsMap` by `eventCoordinate()` = `${kind}:${pubkey}:${dtag}`;
same-coordinate-newer-wins replacement, different authors stored apart.
- Keep the d-tag as the route identifier: `getEventById(dtag)` scans and
returns the newest match (single-publisher in practice). Add
`getByCoordinate()` for precise, author-known lookups.
- `removeEvent(dtag)` deletes every coordinate sharing that d-tag.
Client-side only — the store is rebuilt from relays each session, so no
demo-DB surgery. Covered by vitest unit tests including the cross-author
no-overwrite case.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
327092c022
commit
4b3b905225
2 changed files with 182 additions and 10 deletions
120
src/modules/events/stores/events.spec.ts
Normal file
120
src/modules/events/stores/events.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue