Compare commits
No commits in common. "2febf0926da2162d255af21cd8febc5974ed5b08" and "4b3b905225a442ca4b7b052fda127a9e972be46b" have entirely different histories.
2febf0926d
...
4b3b905225
4 changed files with 13 additions and 97 deletions
|
|
@ -7,19 +7,15 @@ in this file follows from that single fact.
|
||||||
|
|
||||||
## Strictly-monotonic `created_at` per coord
|
## Strictly-monotonic `created_at` per coord
|
||||||
|
|
||||||
**Canonical helper:** `src/lib/nostr/timestamp.ts` —
|
**Canonical:** `src/modules/events/composables/useRSVP.ts` —
|
||||||
`monotonicCreatedAt(lastCreatedAt, now?)` returns `max(now, last + 1)`.
|
`lastPublishAt` map + the `Math.max(now, previous + 1)` line.
|
||||||
Use it for **every** replaceable-event publish; track the last
|
|
||||||
`created_at` per coord (a `Map<coord, number>` 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).
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { monotonicCreatedAt } from '@/lib/nostr/timestamp'
|
|
||||||
|
|
||||||
const lastPublishAt = new Map<string, number>()
|
const lastPublishAt = new Map<string, number>()
|
||||||
|
|
||||||
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
|
lastPublishAt.set(coord, signedEvent.created_at) // only after publish success
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@ 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.
|
||||||
|
|
@ -22,16 +21,12 @@ 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)
|
||||||
|
|
||||||
|
|
@ -70,7 +65,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.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<string>()
|
const coords = new Set<string>()
|
||||||
for (const tag of event.tags) {
|
for (const tag of event.tags) {
|
||||||
|
|
@ -81,8 +76,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: () => {
|
||||||
|
|
@ -121,25 +116,19 @@ 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 = {
|
state.value = { bookmarkedCoords: newCoords, lastEventId: prevState.lastEventId }
|
||||||
bookmarkedCoords: newCoords,
|
;(state.value as any).lastCreatedAt = (prevState as any).lastCreatedAt
|
||||||
lastEventId: prevState.lastEventId,
|
|
||||||
lastCreatedAt: prevState.lastCreatedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
function rollback() {
|
function rollback() {
|
||||||
state.value = prevState
|
state.value = prevState
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and publish updated bookmark list. Use a strictly-monotonic
|
// Build and publish updated bookmark list
|
||||||
// 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: monotonicCreatedAt(prevState.lastCreatedAt),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
content: '',
|
content: '',
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
|
|
@ -161,11 +150,8 @@ 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 = {
|
state.value = { bookmarkedCoords: newCoords, lastEventId: signedEvent.id }
|
||||||
bookmarkedCoords: newCoords,
|
;(state.value as any).lastCreatedAt = template.created_at
|
||||||
lastEventId: signedEvent.id,
|
|
||||||
lastCreatedAt: template.created_at,
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue