From c6f626df0813016a75967979527f8a8745e0dbd2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 13:30:29 +0200 Subject: [PATCH] fix(events): publish bookmarks with monotonic created_at (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relays only push a replaceable-event update to OPEN subscriptions when its created_at is strictly newer than the held version. created_at is second-resolution, so useBookmarks' `Math.floor(Date.now()/1000)` lets two rapid toggles collide in the same second — the second is treated as not-newer and never reaches live subscribers (only a reload shows it). This is the same root cause found while debugging the live ticket count. - Add `monotonicCreatedAt(lastCreatedAt, now?)` = max(now, last+1), a reusable helper for any replaceable-event publisher. - Use it in `toggleBookmark`; track `lastCreatedAt` as a typed field on BookmarkState (drops the `(state as any)` casts). Unit tests cover no-prior, same-second bump, wall-clock tracking, future-dated prior, and a strictly-increasing same-second burst. The aiolabs/events extension's nostr_publisher uses int(time.time()) the same way — flagged in #122 for a follow-up on the backend. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/nostr/timestamp.spec.ts | 36 +++++++++++++++++++ src/lib/nostr/timestamp.ts | 30 ++++++++++++++++ .../events/composables/useBookmarks.ts | 30 +++++++++++----- 3 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 src/lib/nostr/timestamp.spec.ts create mode 100644 src/lib/nostr/timestamp.ts diff --git a/src/lib/nostr/timestamp.spec.ts b/src/lib/nostr/timestamp.spec.ts new file mode 100644 index 0000000..3e5c099 --- /dev/null +++ b/src/lib/nostr/timestamp.spec.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..62bc899 --- /dev/null +++ b/src/lib/nostr/timestamp.ts @@ -0,0 +1,30 @@ +/** + * 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 57281a9..be72cae 100644 --- a/src/modules/events/composables/useBookmarks.ts +++ b/src/modules/events/composables/useBookmarks.ts @@ -3,6 +3,7 @@ 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. @@ -21,12 +22,16 @@ 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) @@ -65,7 +70,7 @@ export function useBookmarks() { }], onEvent: (event: NostrEvent) => { // Only process if newer than what we have - if (state.value.lastEventId && event.created_at <= (state.value as any).lastCreatedAt) return + if (state.value.lastCreatedAt != null && event.created_at <= state.value.lastCreatedAt) return const coords = new Set() for (const tag of event.tags) { @@ -76,8 +81,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: () => { @@ -116,19 +121,25 @@ 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 } - ;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt + state.value = { + bookmarkedCoords: newCoords, + lastEventId: prevState.lastEventId, + lastCreatedAt: prevState.lastCreatedAt, + } function rollback() { state.value = prevState } - // Build and publish updated bookmark list + // 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). const tags: string[][] = Array.from(newCoords).map(c => ['a', c]) const template: EventTemplate = { kind: BOOKMARK_KIND, - created_at: Math.floor(Date.now() / 1000), + created_at: monotonicCreatedAt(prevState.lastCreatedAt), content: '', tags, } @@ -150,8 +161,11 @@ export function useBookmarks() { const result = await relayHub.publishEvent(signedEvent) if (result.success > 0) { - state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id } - ;(state.value as any).lastCreatedAt = template.created_at + state.value = { + bookmarkedCoords: newCoords, + lastEventId: signedEvent.id, + lastCreatedAt: template.created_at, + } return true }