events/docs/rebase-playbook.md
Padreug 15c2276e57
Some checks failed
lint.yml / feat(nostr): publish the active ticket wave, not the roll-up (pull_request) Failing after 0s
feat(nostr): publish the active ticket wave, not the roll-up
Since upstream v1.6.8 price, currency and inventory belong to time-boxed
ticket waves, and the event-level fields `sync_event_ticket_waves`
derives are the PRIMARY wave's price/currency and the SUM of every
wave's stock. The NIP-52 publisher read those, so as soon as an
organiser created a second wave the public card would advertise the
early-bird price after early bird closed and count stock in waves that
had not opened. Refs #61.

`build_nip52_event` now describes the wave a buyer can actually buy
from:

- several waves can be open at once, and a publisher has no one to ask
  which one the buyer wants (the purchase endpoint errors with "Please
  select a ticket wave"), so it advertises the CHEAPEST open wave — the
  price a buyer is able to obtain. Deviation recorded in
  docs/upstream-candidates.md.
- with no open wave, `tickets_available` is 0 and never omitted:
  omission used to mean "unlimited", which #34/#62 removed as a concept.
- `tickets_payment_methods` is scoped to the advertised wave too. It was
  derived from `event.allow_fiat` — the primary wave's — so it could
  offer a fiat rail while `tickets_allow_fiat` was absent and the
  purchase endpoint would refuse it. They are the same fact and now come
  from the same place.

Wave boundaries are time-driven, and every republish we have is
sale-driven, so nothing fires when early bird ends at midnight. Rather
than add a scheduler, a publish records which wave it advertised
(`nostr_published_wave_id`, m004) and the reconciliation sweep compares
that against the wave that would be advertised now, setting
`nostr_publish_pending` on a mismatch — reusing the existing retry path.
NULL means "never published", which the sweep leaves alone so an upgrade
does not republish the whole table on first boot.

The selection rule lives in `models.advertised_ticket_wave` so the
publisher and the drift detector cannot disagree about what is on the
relay.

17 new tests; 114 pass. ruff, black, prettier clean; mypy error set
still identical to HEAD's baseline.
2026-09-28 22:28:09 +02:00

7.5 KiB

Rebase playbook

How to merge an upstream release into this fork without shipping the failures a clean git merge cannot see.

Written during the v1.6.1 → v1.6.8 rebase (#33) after the first two instances showed up within minutes of each other. Both were textually clean merges that would have been semantically wrong in production. Modes C and D were added later in the same rebase, from services.py.

The four failure modes

Git resolves text. Neither of these produces a conflict marker.

A. Missed application

Upstream establishes an invariant and applies it at every call site it knows about. The fork has additional call sites upstream cannot see, so the invariant silently does not hold there.

Worked example. v1.6.8 made event.amount_tickets a per-wave roll-up recomputed by sync_event_ticket_waves, and added that call to get_event and get_events in crud.py. Both merged cleanly. But the fork has four getters upstream never had — get_all_events, get_public_events, get_pending_events, get_events_pending_republish — and two of them publish (/republish-all and the #55 sweep). Without the same call they emit the stale roll-up, so waves would have been wrong on exactly the paths that push to relays, and nowhere else.

B. Changed meaning

Upstream redefines what an existing field means. Fork code that reads it is untouched by the diff and keeps compiling, while now saying something false.

Worked example. After sync_event_ticket_waves, event.price_per_ticket is the primary (first) wave's price and event.amount_tickets is the sum across all waves. Our nostr_publisher.build_nip52_event reads both. Merged untouched, it would advertise the early-bird price after early-bird closed, and count stock in waves that have not opened. See #61.

C. Misplaced conflict boundary

Git anchors a conflict on whatever lines happen to match. When both sides rewrote the same region, an incidental shared line inside it can become the anchor — and everything past that line lands outside the markers, where it reads as cleanly merged.

Resolving only what sits between the markers then leaves both implementations in the file. Python does not complain: the later def silently wins. Since the merge appends upstream after ours, the survivor is upstream's — the fork's version is shadowed without a single warning.

Worked example. In services.py the notification stack conflicted. Ours (220 lines: multipart HTML mail, the QR-card attachment, the Date/Message-ID headers that keep SpamAssassin quiet, npub DM support) appeared between the markers; upstream's showed as 4 lines. But both sides define the same nine functions, and git had anchored on a shared _send_nostr_ticket_notification line — so upstream's entire parallel stack sat below >>>>>>>, looking merged. Taking "ours" and moving on would have left upstream's definitions last in the file and therefore live, quietly reverting every one of those features.

Do not trust the marker as the edit's boundary. Before resolving a hunk, list the function names on each side and compare them to the names already in the file:

grep -o '^\(async \)\?def \w*' <file>.py | sed 's/.*def //' | sort | uniq -d

Run that per file as you resolve, not at the end. ruff does not flag redefinition (verified: F ruleset passes on a duplicated def). Only mypy does, via no-redef — and mypy refuses to run at all while any file in the package still has conflict markers, so the one tool that catches mode C is unavailable for the whole merge. The uniq -d line above is the substitute.

D. Collided names

Ours and upstream independently grew a function with the same name for a different feature. Every resolution that reads as sane — take ours, take theirs, take "the newer one" — silently deletes a feature, and the diff looks like an ordinary reconciliation of one function.

Worked example. Both sides had _ticket_image_url. Ours: the rendered QR ticket-card PNG, always attached, served by this extension off lnbits_baseurl. Upstream's: an organiser-uploaded template, opt-in per wave via use_ticket_image, served off ticket_base_url and returning None when the wave has not enabled it. Same name, different arity, different return type, unrelated features. Resolved by renaming ours to _ticket_card_url and keeping both — upstream's ticket-image upload UI had already merged into index.vue, so dropping their backend would have orphaned live UI.

Signals worth stopping on: the two versions differ in arity, in return type (str vs str | None), or in which setting they build a URL from. Any of those means it is probably not one function with two histories.

The procedure

Run this after the merge resolves and before the release.

1. Enumerate what upstream introduced

MB=$(git merge-base HEAD upstream/main)
# new public names
git diff $MB..<tag> -- '*.py' | grep -E "^\+(def |class |async def )"
# fields whose meaning was redefined
git show <tag>:models.py | sed -n '/^def sync_event_ticket_waves/,/return event/p'

Split the result into new symbols (mode A candidates) and redefined fields (mode B candidates).

2. For each new symbol upstream calls, find the fork-only siblings

Ask what category of place the call belongs to — "every function that returns an Event from the DB", "every path that prices a ticket" — then enumerate that whole category in the merged tree and check coverage.

grep -n "sync_event_ticket_waves" crud.py          # where upstream put it
grep -n "^async def get_.*-> \(list\[\)\?Event" crud.py   # where it belongs

The gap between those two lists is the work.

3. For each redefined field, grep the fork-only files

The 30-odd files upstream has never seen are where mode B hides, because nothing in the diff touches them:

git diff --name-only $MB..HEAD > /tmp/fork.txt
git diff --name-only $MB..<tag> > /tmp/up.txt
comm -23 <(sort /tmp/fork.txt) <(sort /tmp/up.txt)   # fork-only files
grep -n "price_per_ticket\|amount_tickets" $(comm -23 ...)

4. Prove each finding before fixing it

Both examples above were confirmed by reading the code path end to end, not inferred from the diff. A wrong theory costs more than the check: during this rebase an inference that amount_tickets "goes stale on sale" was wrong — sync_event_ticket_waves also runs on reads, which only the call-site list showed.

5. Write the reason at the site

Every fix from this procedure gets a comment saying why upstream's diff missed it. That is what stops the next rebase re-dropping it, and it is the only durable record that the omission was considered rather than overlooked.

Checklist

  • migrations.py still byte-identical to upstream
  • New upstream symbols enumerated; each call-site category audited
  • Redefined fields enumerated; every fork-only reader checked
  • Fork-only files listed and grepped for both modes
  • Every resolved file checked for duplicate defs (mode C) — as it is resolved, since mypy cannot run until the whole package is clean
  • Same-named functions on both sides compared by arity and return type before being treated as one function (mode D)
  • Publishing paths specifically audited — they fail silently and externally, so they are the worst place for either mode to land
  • Every fix carries a comment explaining the omission
  • Deviations recorded in docs/upstream-candidates.md