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

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
This commit is contained in:
2026-09-09 08:28:17 -05:00
parent af9f4e191c
commit 6dd4e5e3eb
7 changed files with 407 additions and 52 deletions

View File

@@ -29,6 +29,29 @@
// test-send without a live game event, which is the reason template systems go
// untested.
/**
* The three facts `events/announce.js` puts on EVERY `event.*` payload, declared
* once because they are spread into all seven.
*
* `baseFor()` has always computed them and nothing declared them, so
* `engagementEmit.validatePayload` dropped all three before a template could see
* one — they were absent from the variable list an author picks from, and every
* single event emit logged `emit carried undeclared variables`. Found by the
* Phase 16 acceptance walk, in the DEBUG line it had been writing all along.
*
* All three are optional, and each for its own reason rather than by default: an
* event need not carry a summary, most events belong to no series, and a run
* whose definition has been deleted resolves no zone.
*/
const EVENT_AMBIENT = [
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
description: 'The event’s one-line summary, when it has one.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
description: 'The arc this event belongs to, when it belongs to one.' },
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
description: 'The zone the run was computed in — what a time in the body should be read as.' },
]
const TRIGGERS = [
{
id: 'news.post',
@@ -215,14 +238,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
description: 'The event summary, as authored.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
description: 'The arc this event belongs to, when it belongs to one.' },
...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When the occurrence is due to start, UTC.' },
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
description: 'The shard-local zone the schedule was authored in.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no
@@ -254,14 +272,9 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
description: 'The event summary, as authored.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign',
description: 'The arc this event belongs to, when it belongs to one.' },
...EVENT_AMBIENT,
{ name: 'startsAt', type: 'datetime', required: true, example: '2026-09-12T20:00:00.000Z',
description: 'When it actually started, UTC.' },
{ name: 'timezone', type: 'string', required: false, example: 'America/New_York',
description: 'The shard-local zone the schedule was authored in.' },
// **A presentational fragment, and §4.6.1 convention 1 is what sanctions
// one.** `startsAt` is a `datetime`, which the seam normalises to an ISO
// string — correct as data and unreadable in a mail, and a template has no
@@ -293,6 +306,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: true, example: 'assault',
description: 'The phase key just entered, as authored in the spec.' },
{ name: 'phaseLabel', type: 'string', required: false, example: 'The assault',
@@ -323,6 +337,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
@@ -345,8 +360,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.',
description: 'The event summary, as authored.' },
...EVENT_AMBIENT,
// Counted from `event_run_participants` at emit. Zero on a run whose
// module reported nobody, which is every run until a module collects —
// a template that says "47 took part" needs a number that is never
@@ -377,6 +391,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
// **The operator's reason, and not the run's `last_error`.** `cancel`
// takes a `{ reason }` a human typed for other humans; a diagnostic
// string is for the run console and would read as gibberish in a mail.
@@ -408,6 +423,7 @@ const TRIGGERS = [
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
...EVENT_AMBIENT,
{ name: 'phase', type: 'string', required: false, example: 'assault',
description: 'The phase it failed in, when it had entered one.' },
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',