fix(events): the public calendar, a stranded revert, and three dropped facts (Phase 16a) #198

Merged
whitlocktech merged 1 commits from fix/events-p16a-walk into edge 2026-09-09 13:47:27 +00:00
Member

Three defects the Phase 16 acceptance walk found in code already merged to edge. The suite was green on either side of all three.

1. The public calendar showed neither what is live nor what is recent

EVENTS.md §I: GET /public/events is "the calendar: upcoming, live and recent". Built, it was upcoming only.

eventRuns.db.listInWindow filtered on scheduled_for >= from AND < to, and the shipped page (client/src/routes/public/Events.jsx:28) calls api.publicEvents() with no window at all, so it took the server default of now → +31d. A run that began five minutes ago and has three hours left has scheduled_for < now and was absent; so was one that ended an hour ago.

The site contradicted itself: {"live": true, "current": {...}} on /site/events/<slug> while /site/events served entries: [].

A run is an interval, not an instant. listInWindow now matches a run whose occupied interval overlaps the window. That also closes the admin calendar's identical hole — a run that started last Sunday and is still going was missing from "this week" — which is why the fix is in the shared statement rather than in the public model.

The public default now reaches DEFAULT_RECENT_DAYS (7) back, so "recent" has somewhere to live. The 92-day span cap and the entry cap are untouched, so the anonymous-surface defence is unchanged. Projections are still forecast from now, never into the tail: a slot the runner has already passed did not happen, and a run that WAS created for it arrives as a real row anyway.

2. A resource left reverting by a crash was never reclaimed

claimRevert's own comment said reverting is not claimable "exactly as a step with a live claim is". The analogy was half-built: a step's claim carries claim_expires_at and is reclaimed when the lease lapses — §N4 names that reclaim as the reason the CAS survives a single instance — while a resource in reverting had no expiry and nothing released it.

Observed: the process was killed at 07:45:37.487 mid-teardown, with a lease already flipped to reverting. After restart the row sat at revert_attempts=0, last_error=NULL, untouched, while event cleanup swept {"runs":1} ran every 15s for ever finding nothing it could claim.

And the documented recourse was a silent no-op. §I says POST …/cleanup "is the only thing that clears the counter". It answered 200 and changed nothing — it claims through the same function, and there was no counter to clear because the row was never counted as failed.

It is not only a wrong record: the stranded lease blocked the next run of the same event, which failed with "Seasonal event status is already leased by another run (run 2)" until the shard's own deadline lapsed.

The stale test is updated_at, not a new column — for a reverting row that IS when the claim was taken, and it is not written again until the revert resolves. updated_at is re-stamped explicitly, and that is load-bearing rather than tidy: this connector sends CLIENT_FOUND_ROWS, so affectedRows counts rows matched; without the write a second claimer would still match the row and two passes would revert the same resource. revert_attempts is deliberately untouched — a stale claim is a process that died, not an attempt that failed.

3. Three facts every event announcement computes and none could use

events/announce.js baseFor() puts summary, seriesName and timezone on all seven event.* payloads. Four triggers declared none of them and a fifth declared one, so engagementEmit.validatePayload dropped them: absent from the variable list an author picks from, unusable in a template, and logging emit carried undeclared variables at DEBUG on every event emit.

They are now one shared EVENT_AMBIENT declaration spread into all seven, with the per-trigger copies removed so the seven cannot drift. timezone in particular is the one an event mail wants. engagement-triggers.json regenerated.

Verification

Against a real rig — ServUO 57.4 → sidecar on protocol 7 → this core with module-uo installed from a release-shaped bundle → the app on an emulator:

  • /site/events now shows a live run as "Happening now" beside recent finished ones, in the browser and in the app (the app consumes the same route, so D1 was breaking its calendar too). It showed nothing at all before.
  • The lease stranded by a real mid-teardown crash was reclaimed within one sweep; cleanup_status went pending → complete.
suite                2060 pass, 0 fail
eventRunnerSql       68 pass against a real MariaDB (3 new)

The claimRevert tests live in eventRunnerSql.test.js rather than against a stub, because every part of the answer is the server's — NOW() - INTERVAL, whether ON UPDATE re-stamps, and above all what affectedRows counts. A stub counting changed rows would call the reclaim a failure; one counting matched rows would miss that the second claimer needs the re-stamp in order to lose.

Pairs with Module-uo#33, servuo-plugins# and docs# (Phase 16a).

  • AI-assisted: written with Claude Code (Claude Opus 5).

🤖 Generated with Claude Code

https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4

Three defects the Phase 16 acceptance walk found in code already merged to `edge`. The suite was green on either side of all three. ## 1. The public calendar showed neither what is live nor what is recent `EVENTS.md` §I: `GET /public/events` is **"the calendar: upcoming, *live* and *recent*"**. Built, it was upcoming only. `eventRuns.db.listInWindow` filtered on `scheduled_for >= from AND < to`, and the shipped page (`client/src/routes/public/Events.jsx:28`) calls `api.publicEvents()` with **no window at all**, so it took the server default of now → +31d. A run that began five minutes ago and has three hours left has `scheduled_for < now` and was absent; so was one that ended an hour ago. The site contradicted itself: `{"live": true, "current": {...}}` on `/site/events/<slug>` while `/site/events` served `entries: []`. **A run is an interval, not an instant.** `listInWindow` now matches a run whose occupied interval **overlaps** the window. That also closes the admin calendar's identical hole — a run that started last Sunday and is still going was missing from "this week" — which is why the fix is in the shared statement rather than in the public model. The public default now reaches `DEFAULT_RECENT_DAYS` (7) back, so "recent" has somewhere to live. The 92-day span cap and the entry cap are untouched, so the anonymous-surface defence is unchanged. Projections are still forecast **from `now`**, never into the tail: a slot the runner has already passed did not happen, and a run that WAS created for it arrives as a real row anyway. ## 2. A resource left `reverting` by a crash was never reclaimed `claimRevert`'s own comment said `reverting` is not claimable *"exactly as a step with a live claim is"*. The analogy was half-built: a step's claim carries `claim_expires_at` and is reclaimed when the lease lapses — §N4 names that reclaim as the reason the CAS survives a single instance — while a resource in `reverting` had no expiry and nothing released it. Observed: the process was killed at 07:45:37.487 mid-teardown, with a lease already flipped to `reverting`. After restart the row sat at `revert_attempts=0, last_error=NULL`, untouched, while `event cleanup swept {"runs":1}` ran every 15s for ever finding nothing it could claim. **And the documented recourse was a silent no-op.** §I says `POST …/cleanup` "is the only thing that clears the counter". It answered **200** and changed nothing — it claims through the same function, and there was no counter to clear because the row was never counted as failed. It is not only a wrong record: the stranded lease **blocked the next run of the same event**, which failed with *"Seasonal event status is already leased by another run (run 2)"* until the shard's own deadline lapsed. The stale test is `updated_at`, not a new column — for a `reverting` row that IS when the claim was taken, and it is not written again until the revert resolves. `updated_at` is re-stamped explicitly, and that is **load-bearing rather than tidy**: this connector sends `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows *matched*; without the write a second claimer would still match the row and two passes would revert the same resource. `revert_attempts` is deliberately untouched — a stale claim is a process that died, not an attempt that failed. ## 3. Three facts every event announcement computes and none could use `events/announce.js` `baseFor()` puts `summary`, `seriesName` and `timezone` on all seven `event.*` payloads. Four triggers declared none of them and a fifth declared one, so `engagementEmit.validatePayload` dropped them: absent from the variable list an author picks from, unusable in a template, and logging `emit carried undeclared variables` at DEBUG on **every** event emit. They are now one shared `EVENT_AMBIENT` declaration spread into all seven, with the per-trigger copies removed so the seven cannot drift. `timezone` in particular is the one an event mail wants. `engagement-triggers.json` regenerated. ## Verification Against a real rig — ServUO 57.4 → sidecar on protocol 7 → this core with `module-uo` installed from a release-shaped bundle → the app on an emulator: - `/site/events` now shows a live run as **"Happening now"** beside recent finished ones, in the browser **and in the app** (the app consumes the same route, so D1 was breaking its calendar too). It showed nothing at all before. - The lease stranded by a real mid-teardown crash was reclaimed within one sweep; `cleanup_status` went `pending → complete`. ``` suite 2060 pass, 0 fail eventRunnerSql 68 pass against a real MariaDB (3 new) ``` The `claimRevert` tests live in `eventRunnerSql.test.js` rather than against a stub, because every part of the answer is the server's — `NOW() - INTERVAL`, whether `ON UPDATE` re-stamps, and above all what `affectedRows` counts. A stub counting *changed* rows would call the reclaim a failure; one counting *matched* rows would miss that the second claimer needs the re-stamp in order to lose. Pairs with `Module-uo#33`, `servuo-plugins#` and `docs#` (Phase 16a). - [x] AI-assisted: written with Claude Code (Claude Opus 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
wtclaude added 1 commit 2026-09-09 13:32:46 +00:00
fix(events): the public calendar, a stranded revert, and three dropped facts (Phase 16a)
Some checks failed
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 5m47s
6dd4e5e3eb
Three defects the acceptance walk found in shipped code.

**The public calendar showed neither what is live nor what is recent.** §I says
`GET /public/events` is "the calendar: upcoming, **live** and **recent**". Built,
it was upcoming only: `listInWindow` filtered on `scheduled_for >= from` alone and
the shipped page asks for no window at all, so it took the default of now → +31d.
A run that began five minutes ago and has three hours to go was absent; so was one
that ended an hour ago. The site contradicted itself — `live: true` on
`/site/events/<slug>` while `/site/events` served `entries: []`.

A run is an interval, not an instant. `listInWindow` now matches a run whose
occupied interval OVERLAPS the window, which fixes the admin calendar's identical
hole (a run that started last Sunday and is still going was missing from "this
week"), and the public default reaches `DEFAULT_RECENT_DAYS` back so "recent" has
somewhere to live. Forecasts are still computed from `now`, never from the tail:
a projection into the past would advertise an occurrence that did not happen.

**A resource left `reverting` by a crash was never reclaimed.** `claimRevert`'s
comment said `reverting` is not claimable "exactly as a step with a live claim is"
— but a step's claim carries `claim_expires_at` and is reclaimed when the lease
lapses, and a resource in `reverting` had no expiry and nothing released it. A
process killed mid-teardown stranded the row for good: the sweep skipped it every
15s for ever, `cleanup_status` never left `pending`, and `POST …/cleanup` — the
recourse §I names — answered 200 and did nothing, because it claims through the
same function. On the rig it stranded a lease, which then BLOCKED the next run of
the same event from taking that value until the shard's own deadline lapsed.

The stale test is `updated_at`, which for a `reverting` row is exactly when the
claim was taken, so no column is added. `updated_at` is re-stamped explicitly and
that is load-bearing rather than tidy: this connector sends `CLIENT_FOUND_ROWS`,
so without the write a second claimer would still match the row. `revert_attempts`
is untouched — a stale claim is a process that died, not an attempt that failed.

**Three facts every event announcement computed and none could use.**
`announce.js` `baseFor()` puts `summary`, `seriesName` and `timezone` on all seven
`event.*` payloads, but four triggers declared none of them and a fifth declared
one, so `validatePayload` dropped them, they were absent from the variable list an
author picks from, and every emit logged `emit carried undeclared variables` at
DEBUG. They are now one shared `EVENT_AMBIENT` declaration spread into all seven,
with the per-trigger copies removed so the seven cannot drift.

Verified against a real ServUO + sidecar + website rig: the public page now shows
a live run as "Happening now" beside recent finished ones (it showed nothing at
all before), and a lease stranded by a real mid-teardown crash was reclaimed
within one sweep, taking `cleanup_status` from `pending` to `complete`.

The three `claimRevert` tests live in `eventRunnerSql.test.js` against a real
MariaDB, because every part of the answer is the server's — `NOW() - INTERVAL`,
`ON UPDATE`, and above all what `affectedRows` counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit b92b85c3a9 into edge 2026-09-09 13:47:27 +00:00
whitlocktech deleted branch fix/events-p16a-walk 2026-09-09 13:47:28 +00:00
Sign in to join this conversation.
No description provided.