Skip to content

feat(pinballmap): tie guard for duplicate same-title cabinets (PP-o355.15) - #1798

Open
timothyfroehlich wants to merge 4 commits into
mainfrom
feat/pbm-tie-guard-PP-o355.15
Open

feat(pinballmap): tie guard for duplicate same-title cabinets (PP-o355.15)#1798
timothyfroehlich wants to merge 4 commits into
mainfrom
feat/pbm-tie-guard-PP-o355.15

Conversation

@timothyfroehlich

Copy link
Copy Markdown
Owner

Summary

Server-side only, no UI. Unblocks PP-o355.20 (auto-link), which may not set listed = true until the rule deciding which cabinet may hold a listing exists.

resolveListingHolder() is the single exported source of that rule, so auto-link consumes it rather than re-deriving it — two implementations would drift, and the partial unique index would start rejecting writes one of them believed were legal.

  1. An existing listing is knowledge, so the incumbent wins outright. We created that lmx, so we know whose it is: no tie, however many same-title cabinets share its availability, and even if the incumbent's own availability has drifted into an invalid one (that's a §6 hard flag for the dashboard, not an ambiguity — deliberately kept separate).
  2. Nobody listed → drop availabilities that make Listed invalid (pending_arrival, removed), rank the rest by MACHINE_PRESENCE_RANK, return either the sole best or a tie descriptor.

A tie only ever means we decline to choose. No banner, no hidden actions, no paused syncing — a human clicking "list this one" is the disambiguation. A tie with nobody listed renders identically to "not listed", and that invisibility is the point (refresher §7).

findListingTies() exposes the reportable condition for the admin dashboard (PP-o355.7) without building any page here.

The 23505 backstop was two real defects, not one

Both reproduced by the new integration test before being fixed:

  • machines has two unique constraints and both raise SQLSTATE 23505, so createMachineAction answered a duplicate-listing collision with "Initials are already taken" — an actively wrong diagnosis for a request whose initials were fine and unique.
  • updateMachineAction had no 23505 catch at all, so the same collision surfaced as a generic 500.

Both now name the cabinet that already holds the listing.

One thing worth a careful look

getPostgresErrorConstraint() reads both driver spellings, and that is load-bearing rather than defensive:

  • postgres-js (production) exposes constraint_namenode_modules/postgres/src/connection.js:46 maps wire field n.
  • PGlite (every integration test) inherits pg-protocol's camelCase and exposes constraint.

Reading one spelling passes one environment and silently fails the other. The first cut of this fix did exactly that — green in unit tests, dead in the integration suite — which is how the asymmetry was found. Both are now covered by explicit unit tests.

Test Plan

  • 14 unit tests on resolveListingHolder / findListingTies across the incumbent/no-incumbent × tie/no-tie matrix, plus degenerate inputs — written before the implementation.
  • 6 unit tests on getPostgresErrorConstraint, including both driver spellings and the two-constraints-one-SQLSTATE case.
  • 3 integration tests (PGlite): the collision names the incumbent, updateMachineAction returns VALIDATION not a 500, and a genuine initials collision still reports as an initials problem (regression guard).
  • pnpm run preflight — typecheck, lint, 2024 unit tests, build, 518 integration tests all pass.

Note on smoke E2E: preflight's Mobile Safari smoke reports pre-existing responsive-overflow failures on /m/[initials] tab routes. Bisected to confirm they are not from this branch (which touches no UI) and not a regression from #176287552bad, the commit before #1762 merged, fails worse (3 failed vs 1 failed + 2 flaky today). Filed as PP-h490, including the reason nobody noticed: Mobile Safari smoke runs only in the post-merge, non-required test-e2e-comprehensive job.

Related Issues

PP-o355.15 (epic PP-o355). Unblocks PP-o355.20.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo

…5.15)

PBM's POST /location_machine_xrefs is find-or-create on (location_id,
machine_id), so two PinPoint machines matched to one catalog title cannot each
own a distinct lmx at our location. Migration 0052 mirrors that with a partial
UNIQUE on pinballmap_machine_id WHERE pinballmap_listed.

resolveListingHolder() is the single source of the rule, so auto-link
(PP-o355.20) consumes it rather than re-deriving it — two implementations would
drift and the index would start rejecting writes one of them believed legal.

  1. An existing listing is knowledge, so the incumbent wins outright. We
     created that lmx, so we know whose it is: no tie, however many same-title
     cabinets share its availability, and even if its own availability has
     drifted into an invalid one (that is a §6 hard flag, not an ambiguity).
  2. Nobody listed → drop availabilities that make Listed invalid, rank the
     rest by MACHINE_PRESENCE_RANK, and either return the sole best or a tie.

A tie only ever means WE decline to choose. No banner, no hidden actions, no
paused syncing — a tie with nobody listed is indistinguishable from "not
listed", and that invisibility is the point. findListingTies() exposes the
reportable condition for the admin dashboard (PP-o355.7).

The 23505 backstop turned out to be two real defects, both reproduced by the
new integration test before fixing:

  - `machines` has TWO unique constraints and both raise 23505, so
    createMachineAction answered a duplicate-listing collision with "Initials
    are already taken" — an actively wrong diagnosis for a request whose
    initials were fine.
  - updateMachineAction had no 23505 catch at all, so the same collision
    surfaced as a generic 500.

Both now name the cabinet that already holds the listing.

getPostgresErrorConstraint() reads BOTH driver spellings, which is load-bearing
rather than defensive: postgres-js (production) exposes `constraint_name`
(postgres/src/connection.js:46) while PGlite (every integration test) exposes
`constraint`. Reading one spelling passes one environment and silently fails
the other — the first cut of this fix went green in unit tests and dead in the
integration suite for exactly that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pin-point Ready Ready Preview Aug 1, 2026 10:24pm

Request Review

…g them

`check:rule-ids` bans the bare "rule N" shape outright — it cannot distinguish
a local enumeration from a fragile AGENTS.md rule-number citation, and per its
own docstring banning the pattern is the only sound gate. My comments numbered
the two branches of resolveListingHolder "Rule 1" / "Rule 2" and tripped it.

Naming them INCUMBENT and OPEN CONTEST is better regardless: it says what the
branch decides rather than where it sits in a list, and removes any chance of
reading them as citations of project rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a single, reusable rule for choosing (or declining to choose) which PinPoint machine “holds” a PinballMap listing when multiple cabinets share the same catalog title, and hardens machine write paths to correctly diagnose PinballMap listing unique-index collisions (instead of misreporting them as initials conflicts or surfacing 500s).

Changes:

  • Introduces resolveListingHolder() / findListingTies() to centralize the tie/selection rule for same-title cabinets.
  • Adds getPostgresErrorConstraint() to distinguish which unique constraint triggered a 23505 across both postgres-js and PGlite driver shapes.
  • Updates machine create/update actions to return a specific, user-actionable VALIDATION error on duplicate PinballMap listing collisions, plus new unit + integration coverage.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/test/integration/pinballmap-listing-conflict.test.ts New PGlite integration coverage for the 23505 backstop and correct error messaging.
src/lib/pinballmap/listing-holder.ts Implements the pure selection/tie rule for determining a listing holder among same-title cabinets.
src/lib/pinballmap/listing-holder.test.ts Unit tests covering incumbent/candidate/tie/none outcomes and fleet-wide tie reporting.
src/lib/db/postgres-errors.ts Adds a constraint-name extractor that works across both postgres-js and PGlite error shapes.
src/lib/db/postgres-errors.test.ts Unit tests for constraint-name extraction and for distinguishing multiple 23505 sources.
src/app/(app)/m/actions.ts Differentiates duplicate-listing collisions from initials collisions and returns actionable VALIDATION results.

Comment thread src/test/integration/pinballmap-listing-conflict.test.ts Outdated
Comment thread src/app/(app)/m/actions.ts Outdated
… the guard

Review findings on #1798, all verified before acting.

Extract the 23505 recognition + message into ~/lib/pinballmap/listing-conflict
so all four write paths share one implementation. They had drifted into three
different answers for the same failure, which is exactly what one source
prevents:

  - createMachineAction / updateMachineAction: fixed in the previous commit.
  - linkPinballmapEntryAction returned err("SERVER", …) with a generic string,
    and its own comment deferred the fix to this bead. A duplicate lister is a
    user-fixable condition — unlist the other cabinet — so SERVER made the UI
    treat it as a crash. Now VALIDATION, naming the incumbent.
  - The verify/heal transaction sets pinballmapListed: true with NO catch at
    all. Cabinets A and B share a title, A is listed and holds the lmx, B is
    linked but unlisted; verifying B heals it onto A's lmx and the partial
    unique index rejects it — a 500 in the very duplicate-title case this bead
    exists for, and the one write path the first commit missed.

Also stop claiming a guard that is not running. resolveListingHolder has no
production caller yet — it lands ahead of PP-o355.20 on purpose, because
auto-link may not set listed = true until the rule exists — but the comments
described it in the present tense as "the primary defence" and the 23505 catch
as a mere last resort. At head that catch is the ONLY defence. Both the module
docblock and the integration-test header now say so.

Two doc defects from the previous commit: PBM_LISTED_UNIQUE had been inserted
inside createMachineAction's JSDoc (so the constant was documented as "Create
Machine Action" and the action's block began mid-sentence), and the
driver-asymmetry warning in postgres-errors.ts sat in its own block above the
real JSDoc, orphaned — invisible to editor hover, which is the one place it
most needed to appear. Merged into the attaching block.

Filed PP-o355.29 (P1) for the finding deliberately left out of scope:
pinballmapListed is client-settable, so a form post can record a machine as
listed with no lmx and no PBM write, consuming the unique slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… constraint

Self-review of the previous commit. Routing linkPinballmapEntryAction and the
verify/heal transaction through isPbmListingConflict narrowed them from "any
23505" to "23505 whose constraint_name matches" — and getPostgresErrorConstraint
returns undefined whenever a driver drops the field. That would send a
correctable condition back to being a 500, which is the exact failure the
previous commit set out to remove, and it contradicts the caveat in
postgres-errors.ts telling callers to keep a general fallback.

Both of these writes touch only the listing columns, so the one-lister index is
the only unique constraint they can violate and the bare code is the correct
test. The narrow check stays where it is genuinely needed: create/update, which
can violate EITHER the initials unique or the listing index and must tell them
apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y39wAcWjeH7BKkB8p96eqo
@timothyfroehlich timothyfroehlich added the ready-for-review PR passed CI and has no unresolved review comments label Aug 1, 2026
@timothyfroehlich

Copy link
Copy Markdown
Owner Author

Scope grew after the first review — summary of what landed since

Three commits after 7595a826, all from review findings rather than new scope:

50d4e5ea — cover every listing write path. The first cut fixed createMachineAction and updateMachineAction and stopped there. Two more paths write pinballmapListed:

  • linkPinballmapEntryAction — the real listing entry point — returned err("SERVER", …) for a duplicate lister, with a comment deferring the fix to this very bead. It's user-correctable (unlist the other cabinet), so SERVER made the UI treat it as a crash. Now VALIDATION, naming the incumbent.
  • The verify/heal transaction set pinballmapListed: true with no catch at all. Cabinets A and B share a title, A is listed and holds the lmx, B is linked but unlisted → verifying B heals it onto A's lmx → the index rejects it → 500. That is the duplicate-title case this bead exists for, in the one path I'd missed.

Recognition + message extracted to ~/lib/pinballmap/listing-conflict so all four paths share one implementation. They had drifted into three different answers to the same failure — the drift resolveListingHolder exists to prevent, reproduced in the error handling.

Also in 50d4e5ea: stopped overstating the guard. resolveListingHolder has no production caller yet — it ships ahead of PP-o355.20 deliberately, because auto-link may not set listed = true until the rule exists. But the comments described it in the present tense as the primary defense and the 23505 catch as a last resort. At head that catch is the only defense, and the module docblock and test header now say so, so nobody trusts a guard that isn't running.

e080333e — self-review catch. Routing the two PBM-only catches through isPbmListingConflict narrowed them from "any 23505" to "23505 with a matching constraint name", and getPostgresErrorConstraint returns undefined when a driver drops the field — putting a correctable condition back to a 500, contradicting the caveat I'd written telling callers to keep a general fallback. Those two writes touch only listing columns, so the bare code is correct there. The narrow check stays in create/update, which must tell the initials and listing constraints apart.

Review status

Copilot returned the quota-limited placeholder on head 50d4e5ea (it delivered real reviews earlier today on #1762, #1794 and this PR's 7595a826, then hit the daily budget). The reviewed gate correctly treats that as absent.

/code-review medium ran against 7595a826 and its findings are what 50d4e5ea addresses; I reviewed the delta since by hand, which is where e080333e came from.

CI green, threads resolved, no UI files touched (no screenshots needed). —Claude-PbmListUnlist

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review PR passed CI and has no unresolved review comments

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants