diff --git a/docs/nostr-patterns/replaceable-events.md b/docs/nostr-patterns/replaceable-events.md index f02158b..0cad379 100644 --- a/docs/nostr-patterns/replaceable-events.md +++ b/docs/nostr-patterns/replaceable-events.md @@ -7,19 +7,15 @@ in this file follows from that single fact. ## Strictly-monotonic `created_at` per coord -**Canonical helper:** `src/lib/nostr/timestamp.ts` — -`monotonicCreatedAt(lastCreatedAt, now?)` returns `max(now, last + 1)`. -Use it for **every** replaceable-event publish; track the last -`created_at` per coord (a `Map` when one composable -publishes many coords like `useRSVP.ts`, or a single field when there's -one coord per user like `useBookmarks.ts`' kind-10003 list). +**Canonical:** `src/modules/events/composables/useRSVP.ts` — +`lastPublishAt` map + the `Math.max(now, previous + 1)` line. ```ts -import { monotonicCreatedAt } from '@/lib/nostr/timestamp' - const lastPublishAt = new Map() -const createdAt = monotonicCreatedAt(lastPublishAt.get(coord)) +const now = Math.floor(Date.now() / 1000) +const previous = lastPublishAt.get(coord) ?? 0 +const createdAt = Math.max(now, previous + 1) … lastPublishAt.set(coord, signedEvent.created_at) // only after publish success ``` diff --git a/src/lib/nostr/timestamp.spec.ts b/src/lib/nostr/timestamp.spec.ts deleted file mode 100644 index 3e5c099..0000000 --- a/src/lib/nostr/timestamp.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { monotonicCreatedAt } from './timestamp' - -describe('monotonicCreatedAt', () => { - it('uses now when there is no prior version', () => { - expect(monotonicCreatedAt(null, 1000)).toBe(1000) - expect(monotonicCreatedAt(undefined, 1000)).toBe(1000) - }) - - it('bumps to prior+1 when republished in the same second', () => { - // now == last: a naive floor(Date.now()/1000) would tie and the relay - // would drop the update; we must produce a strictly newer stamp. - expect(monotonicCreatedAt(1000, 1000)).toBe(1001) - }) - - it('tracks wall-clock once enough real seconds have elapsed', () => { - expect(monotonicCreatedAt(1000, 1005)).toBe(1005) - }) - - it('steps past a future-dated prior (clock skew / rapid bursts)', () => { - expect(monotonicCreatedAt(2000, 1000)).toBe(2001) - }) - - it('is strictly increasing across a same-second burst', () => { - let last: number | null = null - const stamps: number[] = [] - for (let i = 0; i < 5; i++) { - last = monotonicCreatedAt(last, 1000) // clock frozen at 1000 - stamps.push(last) - } - expect(stamps).toEqual([1000, 1001, 1002, 1003, 1004]) - for (let i = 1; i < stamps.length; i++) { - expect(stamps[i]).toBeGreaterThan(stamps[i - 1]) - } - }) -}) diff --git a/src/lib/nostr/timestamp.ts b/src/lib/nostr/timestamp.ts deleted file mode 100644 index 62bc899..0000000 --- a/src/lib/nostr/timestamp.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Monotonic `created_at` for replaceable / addressable Nostr events. - * - * Relays only push a replaceable update to OPEN subscriptions when its - * `created_at` is **strictly newer** than the version they already hold - * (verified against our relay). `created_at` is second-resolution, so a - * publisher that stamps `Math.floor(Date.now() / 1000)` can emit two - * versions within the same wall-clock second — the relay treats the - * second as not-newer and never propagates it to live subscribers (it - * only surfaces on a reload / fresh REQ). This is exactly the failure - * seen with rapid bookmark toggles. - * - * Returning `max(now, lastCreatedAt + 1)` guarantees a strictly - * increasing timestamp across successive publishes of the same - * replaceable event, so each version reaches open subscriptions. When - * enough real seconds have elapsed it tracks wall-clock; only same-second - * (or clock-skewed) republishes get nudged forward. - * - * @param lastCreatedAt `created_at` of the previously published version - * (seconds), or null/undefined if none has been published yet. - * @param now Current time in **seconds** — injectable for tests; defaults - * to `Math.floor(Date.now() / 1000)`. - */ -export function monotonicCreatedAt( - lastCreatedAt?: number | null, - now: number = Math.floor(Date.now() / 1000), -): number { - if (lastCreatedAt == null) return now - return Math.max(now, lastCreatedAt + 1) -} diff --git a/src/modules/events/composables/useBookmarks.ts b/src/modules/events/composables/useBookmarks.ts index be72cae..57281a9 100644 --- a/src/modules/events/composables/useBookmarks.ts +++ b/src/modules/events/composables/useBookmarks.ts @@ -3,7 +3,6 @@ import type { EventTemplate, Event as NostrEvent } from 'nostr-tools' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { useAuth } from '@/composables/useAuthService' import { signEventViaLnbits } from '@/lib/nostr/signing' -import { monotonicCreatedAt } from '@/lib/nostr/timestamp' /** * NIP-51 Bookmarks (kind 10003) for saving favorite events. @@ -22,16 +21,12 @@ interface BookmarkState { bookmarkedCoords: Set /** The latest bookmark event we've seen */ lastEventId: string | null - /** `created_at` of the latest bookmark event — used to publish a - * strictly-newer timestamp so relays push the update to open subs. */ - lastCreatedAt: number | null } // Shared state across all component instances const state = ref({ bookmarkedCoords: new Set(), lastEventId: null, - lastCreatedAt: null, }) const isLoaded = ref(false) @@ -70,7 +65,7 @@ export function useBookmarks() { }], onEvent: (event: NostrEvent) => { // Only process if newer than what we have - if (state.value.lastCreatedAt != null && event.created_at <= state.value.lastCreatedAt) return + if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return const coords = new Set() for (const tag of event.tags) { @@ -81,8 +76,8 @@ export function useBookmarks() { state.value = { bookmarkedCoords: coords, lastEventId: event.id, - lastCreatedAt: event.created_at, } + ;(state.value as any).lastCreatedAt = event.created_at isLoaded.value = true }, onEose: () => { @@ -121,25 +116,19 @@ export function useBookmarks() { // signing or publishing fails. Keep lastEventId/lastCreatedAt until // the real event is confirmed. const prevState = state.value - state.value = { - bookmarkedCoords: newCoords, - lastEventId: prevState.lastEventId, - lastCreatedAt: prevState.lastCreatedAt, - } + state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId } + ;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt function rollback() { state.value = prevState } - // Build and publish updated bookmark list. Use a strictly-monotonic - // created_at so a same-second re-toggle still outranks the prior - // version and relays push it to open subscriptions (a bare - // floor(Date.now()/1000) can tie and be silently dropped). + // Build and publish updated bookmark list const tags: string[][] = Array.from(newCoords).map(c => ['a', c]) const template: EventTemplate = { kind: BOOKMARK_KIND, - created_at: monotonicCreatedAt(prevState.lastCreatedAt), + created_at: Math.floor(Date.now() / 1000), content: '', tags, } @@ -161,11 +150,8 @@ export function useBookmarks() { const result = await relayHub.publishEvent(signedEvent) if (result.success > 0) { - state.value = { - bookmarkedCoords: newCoords, - lastEventId: signedEvent.id, - lastCreatedAt: template.created_at, - } + state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id } + ;(state.value as any).lastCreatedAt = template.created_at return true }