fix: publish NIP-52 events with monotonic created_at (#26) #27

Merged
padreug merged 2 commits from fix/monotonic-created-at into main 2026-06-18 12:18:55 +00:00
Owner

Backend counterpart of aiolabs/webapp#122. Fixes #26.

Problem

build_nip52_event() stamped created_at=int(time.time()). NIP-52 calendar events (31922/31923) are replaceable and get republished whenever inventory changes (a ticket sells). Relays only push a replacement to open subscriptions when created_at is strictly newer, so two republishes in the same wall-clock second tie and the second is silently dropped — a connected client's "X tickets remaining" badge stalls until a reload. Same root cause as the webapp's live-ticket-count bug.

Fix

  • nostr_timestamp.pymonotonic_created_at(last, now=None) = max(now, last+1), a tiny pure module mirroring the webapp's monotonicCreatedAt and docs/nostr-patterns/replaceable-events.md.
  • Anchor it on the already-persisted Event.nostr_event_created_at (set after each publish in nostr_hooks.py:45) — no new schema, no migration.
  • The kind-5 delete event is not replaceable, so it keeps plain int(time.time()).

Tests

tests/test_nostr_timestamp.py: no-prior, same-second bump, wall-clock tracking, future-dated prior, strictly-increasing same-second burst (mirrors the webapp's timestamp.spec.ts). Pure-logic verified locally; ruff + black clean.

Known limitation (follow-up in #26)

Two concurrent sales reading the same stored anchor before either persists can still compute the same +1. Full hardening needs a row-level lock / transaction around read-bump-publish-persist. This change fixes the dominant sequential case and is strictly better than time.time().

Versioning / deploy

Bumps config.json1.6.1-aio.6 (aio semver stays ahead of upstream 1.6.1). Tag + aiolabs/lnbits-extensions catalog entry + sha256 are the separate post-merge release step (test on dev LNbits → tag at HEAD → add catalog entry), per the extension version-bump procedure.

🤖 Generated with Claude Code

Backend counterpart of [aiolabs/webapp#122](https://git.atitlan.io/aiolabs/webapp/issues/122). Fixes #26. ## Problem `build_nip52_event()` stamped `created_at=int(time.time())`. NIP-52 calendar events (31922/31923) are replaceable and get republished whenever inventory changes (a ticket sells). Relays only push a replacement to **open** subscriptions when `created_at` is **strictly newer**, so two republishes in the same wall-clock second tie and the second is silently dropped — a connected client's "X tickets remaining" badge stalls until a reload. Same root cause as the webapp's live-ticket-count bug. ## Fix - `nostr_timestamp.py` — `monotonic_created_at(last, now=None)` = `max(now, last+1)`, a tiny pure module mirroring the webapp's `monotonicCreatedAt` and `docs/nostr-patterns/replaceable-events.md`. - Anchor it on the **already-persisted** `Event.nostr_event_created_at` (set after each publish in `nostr_hooks.py:45`) — no new schema, no migration. - The kind-5 delete event is not replaceable, so it keeps plain `int(time.time())`. ## Tests `tests/test_nostr_timestamp.py`: no-prior, same-second bump, wall-clock tracking, future-dated prior, strictly-increasing same-second burst (mirrors the webapp's `timestamp.spec.ts`). Pure-logic verified locally; ruff + black clean. ## Known limitation (follow-up in #26) Two **concurrent** sales reading the same stored anchor before either persists can still compute the same `+1`. Full hardening needs a row-level lock / transaction around read-bump-publish-persist. This change fixes the dominant sequential case and is strictly better than `time.time()`. ## Versioning / deploy Bumps `config.json` → `1.6.1-aio.6` (aio semver stays ahead of upstream 1.6.1). Tag + `aiolabs/lnbits-extensions` catalog entry + sha256 are the separate post-merge release step (test on dev LNbits → tag at HEAD → add catalog entry), per the extension version-bump procedure. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
NIP-52 calendar events (31922/31923) are replaceable and republished
whenever inventory changes (a ticket sells). build_nip52_event stamped
created_at=int(time.time()); relays only push a replacement to OPEN
subscriptions when created_at is strictly newer, so two republishes in
the same wall-clock second tie and the second is silently dropped for
live subscribers — clients' "tickets remaining" badge stalls until a
reload. Same root cause as the webapp fix (aiolabs/webapp#122).

- Add monotonic_created_at() in nostr_timestamp.py = max(now, last+1),
  mirroring the webapp helper + docs/nostr-patterns/replaceable-events.md.
- Anchor it on the already-persisted Event.nostr_event_created_at
  (set after each publish in nostr_hooks.py). The kind-5 delete event is
  not replaceable, so it keeps plain int(time.time()).
- Unit tests mirror the webapp's timestamp suite.

Concurrent same-second sales reading the same stored anchor can still
collide; full hardening (row-level lock) is noted as follow-up in #26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chore: bump config.json version to 1.6.1-aio.6
Some checks failed
lint.yml / chore: bump config.json version to 1.6.1-aio.6 (pull_request) Failing after 0s
cfc2e38a5e
Marks the monotonic created_at fix (#26). aio semver stays ahead of the
upstream 1.6.1 tag per fork versioning rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
padreug deleted branch fix/monotonic-created-at 2026-06-18 12:18:56 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/events!27
No description provided.