fix(events): publish bookmarks with monotonic created_at (#122)

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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-18 13:30:29 +02:00 committed by padreug
commit c6f626df08
3 changed files with 88 additions and 8 deletions

View file

@ -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])
}
})
})

View file

@ -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)
}

View file

@ -3,6 +3,7 @@ import type { EventTemplate, Event as NostrEvent } from 'nostr-tools'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container' import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { signEventViaLnbits } from '@/lib/nostr/signing' import { signEventViaLnbits } from '@/lib/nostr/signing'
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
/** /**
* NIP-51 Bookmarks (kind 10003) for saving favorite events. * NIP-51 Bookmarks (kind 10003) for saving favorite events.
@ -21,12 +22,16 @@ interface BookmarkState {
bookmarkedCoords: Set<string> bookmarkedCoords: Set<string>
/** The latest bookmark event we've seen */ /** The latest bookmark event we've seen */
lastEventId: string | null 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 // Shared state across all component instances
const state = ref<BookmarkState>({ const state = ref<BookmarkState>({
bookmarkedCoords: new Set(), bookmarkedCoords: new Set(),
lastEventId: null, lastEventId: null,
lastCreatedAt: null,
}) })
const isLoaded = ref(false) const isLoaded = ref(false)
@ -65,7 +70,7 @@ export function useBookmarks() {
}], }],
onEvent: (event: NostrEvent) => { onEvent: (event: NostrEvent) => {
// Only process if newer than what we have // 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<string>() const coords = new Set<string>()
for (const tag of event.tags) { for (const tag of event.tags) {
@ -76,8 +81,8 @@ export function useBookmarks() {
state.value = { state.value = {
bookmarkedCoords: coords, bookmarkedCoords: coords,
lastEventId: event.id, lastEventId: event.id,
lastCreatedAt: event.created_at,
} }
;(state.value as any).lastCreatedAt = event.created_at
isLoaded.value = true isLoaded.value = true
}, },
onEose: () => { onEose: () => {
@ -116,19 +121,25 @@ export function useBookmarks() {
// signing or publishing fails. Keep lastEventId/lastCreatedAt until // signing or publishing fails. Keep lastEventId/lastCreatedAt until
// the real event is confirmed. // the real event is confirmed.
const prevState = state.value const prevState = state.value
state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId } state.value = {
;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt bookmarkedCoords: newCoords,
lastEventId: prevState.lastEventId,
lastCreatedAt: prevState.lastCreatedAt,
}
function rollback() { function rollback() {
state.value = prevState 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 tags: string[][] = Array.from(newCoords).map(c => ['a', c])
const template: EventTemplate = { const template: EventTemplate = {
kind: BOOKMARK_KIND, kind: BOOKMARK_KIND,
created_at: Math.floor(Date.now() / 1000), created_at: monotonicCreatedAt(prevState.lastCreatedAt),
content: '', content: '',
tags, tags,
} }
@ -150,8 +161,11 @@ export function useBookmarks() {
const result = await relayHub.publishEvent(signedEvent) const result = await relayHub.publishEvent(signedEvent)
if (result.success > 0) { if (result.success > 0) {
state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id } state.value = {
;(state.value as any).lastCreatedAt = template.created_at bookmarkedCoords: newCoords,
lastEventId: signedEvent.id,
lastCreatedAt: template.created_at,
}
return true return true
} }