Skip to content

feat(sync): make /admin/sync answer what the press did, and what the fan-out covers - #118

Merged
guarzo merged 5 commits into
mainfrom
worktree-sync-critique-fixes
Aug 5, 2026
Merged

feat(sync): make /admin/sync answer what the press did, and what the fan-out covers#118
guarzo merged 5 commits into
mainfrom
worktree-sync-critique-fixes

Conversation

@guarzo

@guarzo guarzo commented Aug 5, 2026

Copy link
Copy Markdown
Owner

From an /impeccable critique of /admin/sync, scoped to P1-P3 plus the .st weight defect.

The problem

Pressing Sync now enqueued an outbox row and re-rendered the strip byte-identical. The page's one write action left no trace until a worker run landed seconds later. Separately, the fan-out's scope lived only in the primary button's label - 50 characters of uppercase mono - so the strip never said which of the seven jobs the press covered.

What this does

  • Queued state. getSyncStatus reads undispatched outbox rows and returns queued per job type; affected rows get a marker. Deliberately not a RowHealth member: folding it in would make one press set four rows actionable and auto-open four drawers at once, the exact failure mode overdue was excluded from auto-open to prevent (view.ts:96-104).
  • Scope in the strip. The seven job types group into three ruled lists (sweep / on-demand / housekeeping), so the button is a verb. Each group is a real <ul role="list" aria-labelledby> - since the four job nouns left the button on the grounds the strip now carries scope, visual-only grouping would have left screen reader users worse off than before.
  • Run collapse. Consecutive identical runs collapse in the drawer.
  • Typography. .worker out of the label register, a RuleHead above the strip, font-weight: 600 on .st, and width caps so values stop pinning to the container edge.

The interesting part

jobsFor in the new src/core/dispatch-plan.ts is the single mapping from an outbox payload to the jobs it targets. Both the worker (which sends) and getSyncStatus (which only asks "is anything queued for this type") read it, so the marker cannot claim a job is queued that the worker would not dispatch.

sameOutcome compares errorSummary, not just status and counts. contacts.ts:245, wanderer.ts:162-166 and discord-roles.ts:190 build it from per-target error lines that counts never reflects, so two partial runs can both show failed: 1 while different characters failed for different reasons. Collapsing those would erase it.

Self-inflicted regression, caught and fixed

Lifting the payload mapping into src/core/ replaced the QUEUES.* constants with bare literals typed jobType: string. Renaming a queue used to be a compile error at every send site; afterwards { jobType: "wanderer" } compiled whether or not the queue existed. PlannedJob was also structural rather than discriminated, and its { jobType } arm subsumed the other two - so { jobType, accountId, discordUserId } typechecked and sendFor would have taken the narrower branch, silently dropping the Discord scoping.

Both fixed in the final commit. All 1024 tests passed the whole time this was broken; only a type-design review found it. Send behaviour is unchanged - queue names, data shapes and both roles: singleton-key exceptions are identical, and tests/dispatcher.test.ts needed no edits.

Verification

Check Result
npx vitest run 76 files / 1024 passed
npm run test:e2e 188 passed
npm run typecheck clean
npm run lint 0 errors, 1 pre-existing <img> warning
npm run format:check clean

Not exercised: real Postgres under load, or a genuinely dead worker. The two deferred items below are reasoned from the code, not observed.

Reviewer focus

  1. sendFor's rewrite (dispatcher.ts:48-79) - the one place a mistake changes what actually gets sent.
  2. sameOutcome's merge conditions - anything it ignores becomes invisible in the drawer.
  3. Whether queued should have stayed out of RowHealth.

Deferred, not fixed here

Eight review findings were left alone. Three worth knowing about:

  • undispatchedPayloads has no LIMIT, and undispatched rows are never purged - so the row count grows without bound exactly while the worker is down, which is when an admin is most likely to load this page.
  • The "picks them up within a few seconds" copy is gated on a 90-minute freshness window. Worker dies at 10:00, admin presses at 11:20, page still promises seconds. This is new risk: the copy was unconditional before, so it read as boilerplate; conditioning it lent it authority the check cannot earn.
  • Collapsing drops the time axis. sameOutcome never compares duration, so a 47-minute degraded run hides among four fast ones, and entry.to is not in the accessibility tree at all.

guarzo added 5 commits August 5, 2026 11:24
Groups job types into sweep/on-demand/housekeeping per which page control
can reach them. Record<JobType, JobGroup> makes a new JOB_CRON key without
an assignment a compile error, matching the JobType exhaustiveness argument.
- Lift the outbox payload -> job types mapping into src/core/dispatch-plan.ts
  (jobsFor), the single source of truth planDispatch and getSyncStatus both
  read, so the admin page's queued marker can never drift from what the
  worker will actually dispatch. planDispatch's behaviour (drop arms,
  RERUNNABLE gate, singleton keys, data payloads) is unchanged.
- getSyncStatus now returns `queued: boolean` per job type, true iff an
  undispatched outbox row's payload targets that job type (member-triggered
  account/discord-user rows count the same as admin-triggered ones). Costs
  one extra unlocked read query (services/outbox.ts: undispatchedPayloads),
  not one per job type.
- Add collapseRuns (src/core/run-summary.ts) to fold consecutive runs sharing
  an identical outcome (status + counts, both finished) into one group entry
  carrying the run count and time range, for the runs drawer.
contacts.ts, wanderer.ts, and discord-roles.ts build errorSummary from
per-target error lines that counts never reflects, so two partial runs can
share status and counts while a different target failed for a different
reason each time. Collapsing them hid the second run's diagnostics behind
the first's, in exactly the view admins open to read them. errorSummary now
joins status and counts in sameOutcome; null and undefined are normalized
to the same "nothing to show" fact, matching that the field is optional.
…ypeset

The page's one write action left the strip byte-identical: an admin pressed
the fan-out, got a notice at the top, and saw no change in the seven rows the
press actually affected. Rows now carry a queued marker fed by undispatched
outbox rows, beside the health token rather than inside RowHealth — folding it
into health would set four rows actionable at once and blow open four drawers
on a single press, the failure overdue was excluded from auto-open to prevent.

Scope moves out of the button label and into the strip. "Sync membership,
contacts, wanderer, discord-roles" spent 48 characters of uppercase mono
storing what the strip can show: three named groups (sweep / on-demand /
housekeeping) answer which rows a press covers, so the button is just a verb.
The groups are three aria-labelledby'd lists, not painted-on labels — the
nouns left the button, so they have to be perceivable non-visually or screen
reader users end up worse off than before.

Drawer rows collapse when consecutive finished runs agree, so a healthy job
stops spending five near-identical rows saying nothing changed.

Typeset defects the design system already documented: .worker leaves the label
register (prose word plus a value, the same test that excluded .push__next),
.st declares the 600 its siblings declare, and a RuleHead heads the strip the
way every other admin data region is headed.
Three findings from the polish pass, plus four comment corrections.

PlannedJob typed jobType as string. Before the lift into core,
planDispatch used the QUEUES constants, so renaming a queue was a
compile error at every send site; the bare literals that replaced them
were checked against nothing. jobType is JobType again.

PlannedJob was also structural rather than discriminated, and its
{ jobType } arm subsumed the other two: a job carrying both accountId
and discordUserId typechecked, and sendFor would have taken the
narrower branch and dropped the Discord scoping. A fourth arm would
have compiled with sendFor untouched, falling through to a global send
with a ${queue}:all singleton key and no error anywhere. It discriminates
on scope now, and sendFor switches with a never arm. No observable send
changes: queue names, data shapes and both roles: singleton-key
exceptions are identical, and tests/dispatcher.test.ts needed no edits.

OutboxPayload was declared verbatim in both core and services, each with
a comment claiming it could never drift. Core owns it; services
re-exports.

Comments: the removed button label was 50 characters, not 48 (three
sites). The runs table's width rationale had Status backwards - it is
the only column with a width constraint, not the only one without. The
group row's font-style has no cascade-order claim on its position, so
it moves below the media query. .strip__health declares no gap, so the
margin cross-reference pointed at nothing.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: aa0d2792-2ecd-4f11-be0b-bd6ad5e59532

📥 Commits

Reviewing files that changed from the base of the PR and between eb8e316 and 1de7bc0.

📒 Files selected for processing (17)
  • e2e/admin.spec.ts
  • e2e/sync.spec.ts
  • src/app/_components/ui.tsx
  • src/app/admin/sync/page.tsx
  • src/app/admin/sync/view.ts
  • src/app/globals.css
  • src/core/dispatch-plan.ts
  • src/core/run-summary.ts
  • src/core/schedules.ts
  • src/services/outbox.ts
  • src/services/sync-status.ts
  • src/worker/dispatcher.ts
  • tests/dispatch-plan.test.ts
  • tests/run-summary.test.ts
  • tests/schedules.test.ts
  • tests/sync-status.test.ts
  • tests/sync-view.test.ts

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo enabled auto-merge (squash) August 5, 2026 17:23
@guarzo
guarzo merged commit d679af9 into main Aug 5, 2026
7 checks passed
guarzo added a commit that referenced this pull request Aug 5, 2026
…120)

* refactor(sync): make /admin/sync stop claiming more than it checked

Eight review findings deferred out of #118.

The page made three assertions it could not support. The queued marker was
a bare boolean, so a row enqueued 2s ago and one wedged 3 days read
identically -- reachable, since startDispatcher swallows dispatch failures
into console.error and retries forever while the cron scheduler keeps the
worker looking fresh. The "picks it up within a few seconds" promise was
gated on a 90-minute freshness window. And "no runs recorded" rendered as
"the worker is not running", asserting from absence of evidence about a
worker that may have booted seconds ago.

All three now state the age instead of a verdict. queuedNotice's worker
argument is required rather than defaulted, so a forgetful caller is a
compile error instead of silently borrowing the reassuring string.

Collapsing consecutive same-outcome runs also dropped the entire time axis:
five hourly runs, one of which took 47 minutes because ESI was degraded,
collapsed to a row that said nothing about the 47 minutes. sameOutcome
deliberately still ignores duration -- comparing it would stop anything
from ever collapsing -- so the span is carried forward and rendered in
Took, which is also where "N runs" had been sitting under a header
promising a duration.

- outbox: group by payload + min(createdAt); the old unbounded SELECT grew
  without bound exactly while the worker was down
- run-summary: group `to` is Date, not Date | null, as its own comment
  already claimed; errorSummary required, normalizeErrorSummary removed
- page: group status drops an unreachable `?? "running"` that would have
  claimed a finished group was running
- dispatcher: RERUNNABLE's comment now says isJobType is the real gate

* refactor(sync): fold the group's min/max duration into one nullable field

Two independent nullables that are always both-null or both-set spell
four representable states for the two that exist, and force every
consumer to test both to rule out the two that cannot happen -- the same
defect class this branch's type-design findings exist to remove.

Also drops the non-null assertion in toGroup's duration map by filtering
on both ends rather than asserting past finishedAt.

* fix(sync): keep the queued marker when its age does not survive the read

undispatchedSummary dropped a row whose min(createdAt) came back null, and
getSyncStatus derived `queued` from the presence of that row -- so an
impossible null would not merely have cost the "3d ago" suffix, it would
have made a job with work waiting in the outbox render as idle. That is
the exact under-reporting the queued marker exists to prevent.

The row is now carried through with a nullable age, presence and age are
tracked separately in the fold, and both marker helpers degrade on their
own: no age means a bare ", queued" and never an escalation, since
escalation is a claim about elapsed time.

* fix(sync): reject inverted and non-finite spans from a group's duration

toGroup subtracted the pair unconditionally, unlike formatDuration, which
has always guarded Number.isFinite and ms >= 0 on the same computation. A
clock adjustment between the two writes inverts the pair, and an Invalid
Date subtracts to NaN -- either would let Math.min/max report a span the
group cannot have taken, and one bad member poisons a row standing in for
several good ones.

Also covers the exact QUEUED_AGE_NOTABLE_MS boundary, which sat between
the existing under- and over-threshold assertions.
guarzo added a commit that referenced this pull request Aug 6, 2026
One conflict, in DESIGN.md's label-register paragraph: both sides rewrote the
`.st` weight note and told opposite stories about it. Main's #130 is the correct
one and is what survives.

This branch had "corrected" the paragraph to say `globals.css` has declared 600
on `.st` for as long as the rule existed, so the document had always been wrong.
Checking the history rather than the current file: `.st` was added in #11 with no
`font-weight` and inherited 400; #118 added the explicit 600 and rewrote
`.st--lead`'s comment to say the weight there was "redundant with `.st`'s own 600
NOW". So the original paragraph described a real inconsistency that was real when
written and has since been fixed -- which is exactly what main says. The
correction was itself the error, made by reading the stylesheet as it stands
today and mistaking that for how it has always stood.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant