---
test_id: example-nextjs-project-board
run_date: 2026-06-16
run_time_utc: "18:05:00Z"
runner: claude-opus (main thread) + real Chromium via Playwright
sandbox_or_env: "docker postgres:16 :5433, pgpaw serve :8080 --cors-origin '*', next dev :3000, chromium-headless"
duration_secs: 300
result: pass
exit_reason: ""
related_commit: "f3ca466"
---

## Summary

Ran the project-board example in a **real browser**: one collection live-syncs a
3-table join (`todos ⋈ projects ⋈ users`), plus a single-table `projects`
collection for renaming. Verified render, add (under a project), toggle, the
headline **cross-table live propagation** (rename a project → every task badge
updates live), and delete — all green, cross-checked against Postgres. Getting
the headline to work required one library fix the browser surfaced: joined (no-pk)
collections key rows by content-hash, so a join-row change arrives as
`delete(old-hash) + insert(new-hash)`; the adapter now reconciles that as an
upsert by the row's `getKey`, so updates originating from joined tables apply.

## Steps Executed

### Step 1: Stack up (3-table schema, init, serve --cors-origin, dev)
**Primary observation**: schema created projects/users/todos + seeds (Launch/Backlog, Ada/Linus); `/healthz` ok; page `200`.
**Awareness check**: same infra notes as todos — port 5433, `--cors-origin '*'`, navigate via `localhost`.
**Judgment**: pass

### Step 2: Render (real browser)
**Primary observation**: heading "Project board" + chips `["Launch ✎","Backlog ✎"]` rendered. No pageerror.
**Judgment**: pass

### Step 3: Add a task under a project
**Action run**: select project "Launch", type "wire sync", Add.
**Primary observation**: row appears with the "Launch" badge; psql → `wire sync|p1` (correct project_id).
**Judgment**: pass

### Step 4: Toggle
**Action run**: click the row checkbox.
**Primary observation**: psql → `wire sync|t`. (Toggle on a join row arrives as delete+insert and is reconciled via the adapter upsert.)
**Judgment**: pass

### Step 5: HEADLINE — rename project → live cross-table propagation
**Action run**: click the "Launch" chip; the rename prompt auto-accepts "Launchpad".
**Primary observation**: the task row's project **badge** changed to "Launchpad" live (no reload); psql → `projects.name='Launchpad'`. The rename touched only the `projects` table, yet the joined task row updated — the join is genuinely live.
**Judgment**: pass

### Step 6: Delete (via UI)
**Primary observation**: row removed; psql `count` → 0.
**Judgment**: pass

## Environment Observations

- PgPaw recomputes the join when ANY joined table changes (a `projects` UPDATE
  emitted an `up-to-date` with the txn's txid), so cross-table tracking is correct
  on the server. The browser gap was purely the client reconciliation of
  hash-keyed delete+insert.
- Direct PgPaw live SSE on the join showed the update as
  `insert(new row_hash) + delete(old row_hash)` (verified by curl), confirming the
  no-pk keying.

## Evidence

- E2E driver `examples/nextjs-project-board/e2e-board.mjs` → `RESULT: PASS`, exit 0.
- DB transitions: add `wire sync|p1` → toggle `|t` → rename `projects.name=Launchpad` (badge live) → delete count 0.
- Raw join delta on a fresh project rename:
  `{"op":"insert","row":{...,"project":"Shipyard","id":"31549e..."}}` + `{"op":"delete","key":"<old-hash>"}`.

## Root Cause Analysis (resolved)

A joined query has no single primary key, so PgPaw keys diff rows by a content
hash; any change to a joined row (toggle, or a parent rename) re-hashes the row
and is emitted as `delete(old-hash) + insert(new-hash)`. The adapter wrote the
insert under `getKey`=task-id (already present) and the delete under the unknown
old hash, so TanStack DB treated it as a duplicate insert and dropped it — the UI
never updated. Fix (in `@pgpaw/tanstack-db`): track live keys; an `insert` whose
`getKey` already exists is written as an `update` (upsert), and `delete` only
applies to a tracked key. Single-table behavior is unchanged (its updates arrive
as `op:update` keyed by pk).

## Deviations

- Postgres port 5432→5433; example linked via `link:` dep + temporary `playwright`
  devDep + `e2e-board.mjs` (test-only, reverted/removed in cleanup).
- Bundled Chromium (system Chrome needs sudo).

## Next Actions

- [x] Fix joined-collection update reconciliation (adapter upsert by getKey).
- [x] Full browser pass: render + add + toggle + rename-propagation + delete.
- [ ] Known remaining limitation (documented): a cross-client/psql DELETE of a
  joined row still reconciles on reset, not instantly (no stable key on the
  delete event). Single-table collections delete instantly.
