Skip to content

Put the boards on Supabase: accounts, persistence, live collaboration, sharing - #5

Merged
boubou666 merged 16 commits into
mainfrom
supabase-backend
Aug 4, 2026
Merged

boubou666 merged 16 commits into
mainfrom
supabase-backend

Conversation

@boubou666

@boubou666 boubou666 commented Aug 4, 2026 •

Copy link
Copy Markdown
Owner

Boards move from Web Storage to Postgres, and gain accounts, live collaboration
and sharing. Ten commits, each one a working step.

Based on react-shell, not main — the board-scoped BoardRepository this
builds on lives there and is still unmerged. Review that one first, or read this
one as stacked on it.

What changed

Accounts A first visit is signed in anonymously — a real auth.users row, so a guest's boards are covered by the policies from the first card. Registering attaches an email to that same user, so the boards come along.
Persistence createSupabaseRepository satisfies the same contract as the Web Storage one. Nothing above shell/storage.js knows which it is holding.
Live editing The op log already was the wire format; platform/sync.js takes that literally. What a client applies locally is exactly what it sends.
Write authority One elected writer per board, plus a version guard for the case election cannot see.
Cursors Other people's pointers, in world coordinates, over broadcast rather than presence.
Sharing Invite links, roles, a members list, revoke, and leave.

A build with no VITE_SUPABASE_URL is unchanged — Web Storage, no accounts,
no gate. That is what the published GitHub Pages site is, and what most of the
test suite runs against.

Things worth a reviewer's attention

  • board_role() must stay SECURITY DEFINER. No behavioural test catches
    its removal — every policy tests owner_id first and OR short-circuits ahead
    of the call, so the recursion it would cause is one reordered disjunct away.
    test/db/rls.test.js asserts prosecdef directly for that reason.
  • save() updates then inserts, rather than upserting. An upsert must name
    owner_id, and an editor saving someone else's board would send their own id
    for a column freeze_board_owner refuses to let change.
  • The order op merges rather than replaces. A sender cannot name an object
    they have not received yet, and taking their array literally dropped a
    concurrent addition out of the document entirely.
  • Outbound ops are throttled on a timer, not requestAnimationFrame. rAF
    stops in a background tab; the two-page test failed until this changed.
  • Invite tokens are the whole secret: 122 random bits, never derived from the
    board id, and the invite row is readable only by the owner. Redeeming is a
    definer function because the point is to act on a row the caller cannot see.
  • board_people() resolves user ids to email addresses, so it is bounded to
    the owner asking about their own board. It is a members list, not a lookup.

Bugs found and fixed on the way

  • hydrate() replaced the document with the snapshot, discarding anything made
    while the board was still loading — and the canvas is deliberately interactive
    during exactly that window. Invisible against Web Storage; a round trip against
    a network.
  • A board deleted while someone had it open was recreated by their next
    autosave, because a save that updated nothing fell through to an insert.
  • A shared board's card offered Delete, which the policies refuse — and the
    refusal looked like a board that came back.

Tests

  • 398 passing with a local stack; 318 without, where the suites that need
    one skip.
  • Coverage 99.0% / 99.1%, gate at 95%. Files only reachable with a stack
    are reported but excluded from the total when it is absent, with a line saying
    so — averaging an unreachable file in would quietly lower the bar for every
    other file.
  • Nothing is stubbed. The auth, repository, sync and sharing suites sign in real
    anonymous users and talk to real GoTrue, PostgREST and Realtime, so a policy
    that would refuse the app refuses the tests.
  • CI now runs npx supabase start in the main job, so neither those suites nor
    the RLS ones can skip before a merge (~1–3 min of image pulls per run).
  • openApp({ isolated: true }) gives a page its own browser context. Two tabs on
    one origin share Web Storage, so signing the second in took the first's session
    away — a sharing test would have quietly become one person sharing with
    themselves.

Not done

  • Boards already in a browser's Web Storage are not adopted when a project is
    configured. Nothing is at risk yet — the deployed build has no
    VITE_SUPABASE_* — but this has to be decided before the backend is switched
    on in production.
  • A refused save is not recovered from: the writer keeps its document and stops
    persisting until it reads the board again.
  • Ops emitted between reading the snapshot and joining the channel are missed
    until a reload.
  • No notification that a board has been shared with you; it appears in the list.

🤖 Generated with Claude Code

https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN

Summary by CodeRabbit

  • New Features
    • Added account registration, sign-in, guest access, and sign-out.
    • Added cloud-saved boards with offline browser storage fallback.
    • Added real-time collaboration, presence indicators, and shared cursors.
    • Added sharing links, viewer/editor roles, membership management, and leave-board controls.
    • Added loading states and safer handling during board creation, editing, and navigation.
  • Documentation
    • Expanded guidance for collaboration, persistence, sharing, and known limitations.
  • Tests
    • Added comprehensive coverage for authentication, sharing, synchronization, persistence, and access controls.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The application now supports Supabase authentication, asynchronous persistent boards, realtime operation and cursor collaboration, invite-based sharing, row-level security policies, and local integration testing. Local storage and offline authentication remain available without Supabase configuration.

Changes

Supabase collaboration

Layer / File(s) Summary
Supabase configuration and database controls
package.json, .github/workflows/ci.yml, supabase/*, test/db/*
Adds Supabase configuration, migrations, board and membership policies, invite functions, database scripts, RLS tests, and CI lifecycle management.
Authentication and account access
src/platform/auth.js, src/platform/supabase.js, src/shell/auth.js, src/shell/RequireAccount.jsx, src/components/Account*.jsx
Adds anonymous sessions, email registration and sign-in, offline authentication, account subscriptions, protected routes, and account controls.
Asynchronous storage and app hydration
src/platform/storage.js, src/platform/supabase-repository.js, src/shell/storage.js, src/app.js, src/core/autosave.js
Makes repository operations asynchronous, adds Supabase persistence with optimistic versions, buffers edits during hydration, and gates autosave by writer authority.
Realtime synchronization and cursors
src/core/store.js, src/core/scheduler.js, src/platform/sync.js, src/platform/cursors.js, src/shell/sync.js, src/components/BoardCanvas.jsx
Adds validated local and remote operations, throttled broadcasts, presence-based writer election, cursor synchronization, and canvas integration.
Sharing and board navigation
src/platform/sharing.js, src/shell/sharing.js, src/App.jsx, src/routes/*, src/components/ShareDialog.jsx
Adds invite redemption, role management, member removal and leaving, share-link routing, board sharing controls, and asynchronous board-list and title handling.
Validation and documentation
test/node/*, test/browser/*, test/helpers/*, test/run.js, test/coverage.js, README.md, src/styles/*, .gitignore
Adds unit, integration, and browser coverage; supports isolated Supabase test sessions; documents the collaboration model; and adds account, sharing, and cursor styles.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant App
  participant Store
  participant Realtime
  participant Repository
  User->>App: Open board
  App->>Repository: Load persisted board
  Repository-->>App: Return board snapshot
  App->>Store: Hydrate and replay local operations
  User->>Store: Edit board
  Store->>Realtime: Broadcast local operations
  Realtime->>Store: Apply validated remote operations
  Store-->>App: Render synchronized board
  App->>Repository: Autosave when elected writer
Loading

Possibly related PRs

  • boubou666/peerthink#3: Extends the earlier createApp, autosave, storage repository, and board persistence APIs with asynchronous Supabase-backed persistence.
  • boubou666/peerthink#4: Extends the React shell, routing, BoardCanvas, storage, and test infrastructure with authentication and collaboration features.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: migrating board storage to Supabase while adding accounts, persistence, live collaboration, and sharing features. It directly reflects the primary objectives and scope.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch supabase-backend

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

@boubou666

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@boubou666
boubou666 changed the base branch from react-shell to main August 4, 2026 13:07
Janacek and others added 10 commits August 4, 2026 15:07
Every repository method now returns a promise. Web Storage answers instantly
and could have stayed synchronous, but the contract is the one a server-backed
repository has to satisfy, and callers written against a synchronous seam are
callers that get rewritten the day the boards live on a server.

createApp stays synchronous — building the object graph needs no I/O — and
gains hydrate(), which does the round trip. The canvas mounts and accepts
edits while the board is still in flight, and destroy() cancels a hydrate that
has not landed yet: React unmounts a route far faster than a load completes,
and writing to the store afterwards would repopulate a torn-down app and start
an autosave with nothing left to stop it.

Two bugs the async seam exposed, both invisible against Web Storage and
routine against a network:

  - a board title read at mount could resolve after the user had renamed the
    board, putting the stale title back on screen
  - the list page claimed "No boards yet" before the read had resolved, which
    tells someone with boards that they have none

The list page now distinguishes "not read yet" from "no boards", and marks
reads and writes in flight so the browser tests wait for a settled list rather
than assuming a click has already landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmY2ZNmFSyP86hxGPPNuCX
boards and board_members, shaped by the BoardRepository contract rather than
the other way round: list() is one indexed query, load() is a primary key
lookup, save() is an upsert. Ownership is immutable, updated_at is stamped
server-side — a client clock is not evidence of recency — and the document
check mirrors isBoard(), so a malformed board is refused at the same place
whichever repository is in front of it.

Policies are exercised against a real Postgres in test/db/, every statement
running as `authenticated` with a JWT claim the way PostgREST issues it. RLS
is what keeps one person's boards out of another's, and it is not code that
can be reviewed into correctness.

board_role() is SECURITY DEFINER, and the reason is worth recording because a
behavioural test cannot catch it. Called from the policies on boards, it also
reads boards; under invoker rights that read is subject to the policy that
called it, and Postgres unwinds it as `stack depth limit exceeded`. The schema
survives without the keyword today only because every policy tests owner_id
first and OR short-circuits before the call is reached — incidental, not
structural, and one reordered disjunct from breaking. So the test asserts
prosecdef directly rather than pretending behaviour covers it.

test/db/ stays out of `npm test`: it needs a Postgres, not a browser, and
skips itself when DATABASE_URL is unset. stub-auth.sql installs the auth
surface a bare Postgres lacks, so the policies can be tested before anyone has
Docker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmY2ZNmFSyP86hxGPPNuCX
Generated by the CLI rather than written by hand, so the keys match the
installed version instead of a guess at it. Three changes on top: the project
id (init takes the working directory name, which here is the worktree), the
site URL pointed at Vite rather than the static server, and anonymous sign-ins
turned on.

Anonymous sign-in is what lets a first visit get a real auth.users row without
an account, so boards are server-backed from the first card instead of sitting
in localStorage until someone signs up. Attaching an email later upgrades that
same user, which is what stops the migration losing their work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BmY2ZNmFSyP86hxGPPNuCX
A first visit signs in anonymously. That is a real row in auth.users, so the
boards a guest makes are covered by the policies from the first card rather
than sitting outside them until someone decides to register; registering
attaches an email to that same user, which is what keeps their work. The gate
in RequireAccount is what stops a board list rendering before the session
settles — every read below it goes through row level security, and an empty
workspace on screen for a user who has boards is a lie the loading state does
not tell.

Everything above platform/auth.js works with an account — { id, email, guest }
— never a Supabase session, so the same shell runs against a project, against
nothing at all, and against a stub. A build with no VITE_SUPABASE_URL is the
local one and the published one: offline auth, no gate, no account menu.

start() memoises its promise because StrictMode mounts every effect twice, and
two anonymous sign-ins are two users — the second of which is the one that
sticks, with none of the first one's boards. A failed start drops the memo so
the retry is not permanently answering with the cached rejection.

Tested against a real Supabase rather than a fake one. The parts worth doubting
are exactly the ones a fake would have to invent — that a first visit produces
one guest, that registering keeps the same user id, that a rejected password
does not let anyone through — so test/run.js finds a local stack, serves a
second Vite origin that has been handed the project, and drives the gate there.
No stack, no suite: the account UI is then reported but left out of the
coverage total, with a line saying so, because averaging an unreachable file
into the number quietly lowers the bar for every other file. CI starts a stack,
so neither that suite nor the RLS one can skip before a merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
createSupabaseRepository satisfies the same contract as the Web Storage one —
list / load / save / rename / remove, every call async, every call total — so
nothing above shell/storage.js can tell which of the two it is holding. That
was the point of making the contract async in the first commit of this branch,
before there was anything asynchronous behind it.

Access is not enforced in the client. list() is a select with no where clause,
which is the correct way to ask for "my boards": the policies are what answer
it, and a filter here would be a second, weaker copy of a rule that already
exists in one place.

save() updates and then inserts if there was nothing to update, rather than
upserting. An upsert has to name owner_id, and an editor saving a board someone
else owns would be sending their own id for a column freeze_board_owner refuses
to let change; update-then-insert sends owner_id only on the path that really
creates the row. Two tabs opening the same new board both fall through to the
insert and one loses on the primary key, so that case retries the update it
should have done — which is also, unchanged, how a viewer's save comes back
false.

Tested at both ends against a real stack. The contract tests sign in two
anonymous users and drive them through PostgREST with exactly the authority the
browser has, including the case that would be data loss rather than a refused
request: a stranger's save falling through to an insert must not land on a
board that already exists. The browser suite then proves the shell picked this
repository at all — an edit flushed through autosave is still there after a
reload, and the next account to sign in sees none of it.

safeLocalStorage moves to its own module: the repository now depends on the
account, so a helper shared with the auth client cannot live in either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
Every change was already a list of plain, serialisable ops with a computable
inverse; this is the commit that takes that literally. What a client applies
locally is exactly what it sends, and what it receives goes through the same
apply() the local UI uses — no second representation of a change, and so no
second implementation to keep honest.

Ops now carry an origin. A received one is applied with record: false, which
keeps somebody else's edit out of your undo stack, and marked REMOTE, which is
what stops it going straight back out.

The z-order op could not survive replication as it stood. `{ t: 'order' }`
names the whole array, and a sender cannot name an object they have not
received yet — applying their array literally dropped a concurrent addition out
of the order, out of all(), out of the render, and out of the next snapshot.
The object stayed in the map and left the document. An incoming order now
decides the relative depth of everything it mentions and leaves everything else
at the depth it already had, which also makes the op idempotent, which a
message that can arrive twice has to be. `add` is idempotent for the same
reason.

The channel is private, so joining it is authorised by row level security on
realtime.messages — the same board_role() that guards the table, with receive
and send split the way viewer and editor already are. That table has RLS on and
no policies by default, so the migration ships in the same change as the code
that joins it; without it every join is refused, which is the right way round.

Sending is throttled on a timer rather than batched to a frame. The first op of
a gesture goes out immediately and the rest at 50ms, so a drag is a handful of
messages instead of one per pointer move — and a timer keeps running in a tab
the compositor has stopped drawing, which requestAnimationFrame does not. That
was not a theory: the two-page browser test failed until it was a timer,
because the sending tab was in the background.

Two people editing at once still write their own snapshots on their own timers,
so the durable document is last-writer-wins until write authority lands. Ops
emitted between reading the snapshot and joining the channel are missed. Both
are in the README rather than implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
Ops kept the live documents identical, but the row is written by whole-document
save and every editor had an autosave pointed at it. While everyone was in sync
that was merely wasteful — the snapshots were identical — and when one of them
was not, it wrote its stale document over work it had never seen. Broadcast
made that both rarer and more expensive.

Two halves, because neither is enough on its own.

Election is the first. Presence names everyone on the board, the earliest
joiner writes, ties break on id, and every client computes that from the same
state and reaches the same answer without anyone deciding. A newcomer does not
take the job from whoever is already doing it, and when the writer leaves the
handover is announced so the client taking over saves what the last one may not
have got round to. A board with nobody else on it, or one where the channel
never came up, is a board this client writes.

The version guard is the second, and it covers exactly what election cannot
see: a client that has lost the channel elects itself and keeps saving. Its
update names the version it last read, and there is no longer a row at that
version. The counter is bumped by a trigger rather than chosen by the client —
a writer that could pick the next number could pick one that keeps it winning —
and it belongs to `doc` alone, so a rename is not a competing edit to the
document and does not refuse the next honest save.

Reading is what earns the right to write, which also fixes something that was
wrong before: a board deleted while someone had it open used to be recreated by
that client's next autosave, because a save that updated nothing fell through
to an insert. A repository that has read the board no longer takes that path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
Cursors are broadcast rather than presence updates. Presence diffs a set and
fans the whole thing out on every change, which is the right shape for a list
of people and the wrong shape for something that moves at pointer rate — so
presence keeps doing what it was already doing for the election, says who is
here, and carries their name, and positions travel as their own throttled
event. A name repeated on every message would be paid for hundreds of times a
minute.

Positions are in world coordinates, so a cursor points at the thing its owner
is pointing at rather than at a place on their screen: two people at different
zooms still agree about what is being pointed at, which is the only reason to
draw someone else's pointer at all. They are drawn in the overlay, which is
screen space, so the arrow and the name stay one size at any zoom — the same
reasoning as the selection handles. The browser test pans the *receiving* page
and expects the cursor to move with the board, because that is the difference
between the two.

A cursor leaves when its owner's pointer leaves, and again when presence says
they have gone. A viewer's cursor is never seen: sending on the channel is an
insert into realtime.messages, and the policy that stops a viewer broadcasting
ops stops them broadcasting a position. Making that one visible means letting
viewers write to the channel, and ops are applied by whoever receives them.

Fixes a real bug found by the test for it. hydrate() replaced the document with
the snapshot, so anything made while the board was still loading was discarded
when it landed — and the canvas is deliberately interactive during exactly that
window. Against Web Storage the window was a microtask and nobody ever saw it;
against a network it is a round trip, and it is when an impatient person starts
a card. Those ops are now kept and replayed onto the snapshot, which they can
be because they are the same ops a remote client sends and the same apply()
handles them: add is idempotent, del and set ignore what is not there, and
order merges. Replayed unrecorded, so undo does not walk back past the load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
board_members had policies and no way in. The obvious way in — invite by email
— cannot be built on what is here: auth.users is not readable by
`authenticated`, and most people on this app are anonymous and have no address
to be invited at. A link needs neither, and the person following one needs no
account beyond the guest session anonymous sign-in already gave them.

One live link per board, and it says what it grants. Changing the role changes
what the outstanding link is worth rather than minting a new one, because a
link already pasted into a chat should start granting the new role rather than
quietly dying. Revoking deletes it and the people who joined stay: they are
rows in board_members now, and the link is not what holds them there.

The token is the whole secret, so it is 122 bits from gen_random_uuid() rather
than anything derived from the board, and the invite row is readable only by
the owner — an editor is on the board and still cannot see how to hand it out.
Redeeming is a SECURITY DEFINER function because the point is to act on a row
the caller cannot see; it takes no identity, adds only the caller, and only at
the role the owner chose. Redeeming twice is a no-op rather than an error, and
`do nothing` rather than an upsert, so following a viewer link cannot cost an
editor their access. Every reason a token might not work gives the same answer.

board_people() is the other definer function, and it is the one worth being
careful about: it resolves user ids to addresses, so it is bounded to the owner
asking about their own board. There is no way to ask it about an address, only
about a board — it is a members list, not a lookup.

The test harness gains isolated browser contexts, and needed them. Two tabs on
one origin share Web Storage, so signing the second one in took the first one's
session away — the owner's own page quietly became the invitee, and the share
dialog correctly told it the board was not its own. A second person needs a
second browser context, which is now what `openApp({ isolated: true })` gives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
The policy already allowed it — board_members_delete has always said an owner
may take anyone off a board and anyone may take themselves off one. What was
missing was any way to ask, and something worse: a shared board's card offered
Delete, which the policies refuse. remove() answers true for a delete that
matched nothing, so the board came back on the next read and looked like a bug
rather than a refusal.

So the card offers what is actually available. That needs the list to know
whose board it is looking at, which is one more column on a query it was
already making — asking board_role() per row would have been a round trip
each. The local repository reports every board as owned, because nobody else
can reach a browser's own storage, and a caller should not have to know which
repository it is holding to read a summary.

leave() is its own method rather than remove(boardId, myOwnId): it is a
different thing to want, and nobody should have to look up their own id to do
it. It is also the one place a `select` on a delete earns its keep — leaving a
board you were never on has to be false, and a delete that matched nothing is
otherwise indistinguishable from one that did.

An owner cannot leave. They have no membership row to hand back, and a board
with no owner is one nobody can share, delete, or ever get back — deleting your
own board is what Delete is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
@boubou666

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (21)
src/shell/storage.js-10-14 (1)

10-14: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify second-device access for anonymous accounts.

The PR flow starts users with anonymous accounts. Registration attaches an email identity to that account. Change “reachable from a second device” to state that the user must register and sign in with the same account first.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shell/storage.js` around lines 10 - 14, Update the storage behavior
documentation near the shared auth decision to clarify that boards become
accessible on a second device only after the user registers and signs in with
the same account, replacing the vague “reachable from a second device” wording.
test/node/sharing.test.js-125-134 (1)

125-134: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The "owner is listed first" claim holds by alphabetical accident.

board_people orders by role desc, so the position of the owner depends on the role strings. Descending, viewer sorts before owner, and owner sorts before editor. This test uses an editor member, so ['owner', 'editor'] passes — but the same expectation with a viewer member would produce ['viewer', 'owner']. The test name states a general invariant that the query does not provide.

Either add a viewer case to pin the real ordering, or narrow the test name to the roles under test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sharing.test.js` around lines 125 - 134, The test name claims a
general invariant that "the owner is listed first", but the test only verifies
this with an editor role. Since the database query orders by role descending
(where viewer > owner > editor alphabetically), this invariant would fail with a
viewer role. Either add a second assertion case using a viewer role alongside
the existing editor case to pin the actual ordering behavior, or rename the test
to specify that it only tests the owner-and-editor scenario instead of making a
general claim about owner ordering.
test/node/sharing.test.js-104-104 (1)

104-104: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

board({ objects: [] }) passes an argument that the helper ignores.

board is declared at line 42 as () => ({ v: 1, order: [], objects: [] }) and takes no parameters. The { objects: [] } argument is discarded, so this call is identical to board(). The editor test at line 114 builds its payload inline, which confirms the helper has no override support. Drop the argument, so the call does not suggest a payload that it does not produce.

💚 Proposed fix
-    assert.equal(await bob.repository.save(id, board({ objects: [] })), false);
+    assert.equal(await bob.repository.save(id, board()), false);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sharing.test.js` at line 104, Update the save call in the sharing
test to invoke the zero-argument board helper directly, removing the ignored `{
objects: [] }` argument. Keep the existing board helper behavior and assertion
unchanged.
src/routes/JoinPage.jsx-22-35 (1)

22-35: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A rejected redeem leaves this page loading forever.

sharing.redeem(token) has no rejection handler. If the call rejects — offline, DNS failure, a 5xx that the client surfaces as a thrown error — neither navigate nor setFailed runs. The page then shows "Opening the board…" permanently, and the loading branch renders no Link, so the user has no way out of this route. The failure branch is already the correct answer for a token that cannot be redeemed.

🐛 Proposed fix
-    sharing.redeem(token).then((boardId) => {
-      if (!live) return;
-      if (boardId) navigate(`/b/${boardId}`, { replace: true });
-      else setFailed(true);
-    });
+    sharing
+      .redeem(token)
+      .then((boardId) => {
+        if (!live) return;
+        if (boardId) navigate(`/b/${boardId}`, { replace: true });
+        else setFailed(true);
+      })
+      // A link that could not be redeemed and a link that failed to answer
+      // are the same thing from here: ask for a new one.
+      .catch(() => {
+        if (live) setFailed(true);
+      });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/JoinPage.jsx` around lines 22 - 35, Update the redeem flow in the
useEffect around sharing.redeem so rejected promises also transition to the
existing failure state. Add a rejection handler that respects the live
cancellation guard before calling setFailed(true), while preserving the current
successful navigation and cleanup behavior.
src/routes/BoardPage.jsx-66-76 (1)

66-76: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Report or revert a rename that the repository refuses.

setTitle(trimmed) applies the new title optimistically. If repository.rename returns false and the fallback repository.save also returns false, the code discards that answer. A viewer on a shared board hits exactly this path: the policies refuse both writes, and the bar keeps showing a title that was never stored until the next load. Restore the previous title, or surface an error, when both writes fail.

🐛 Proposed fix to restore the committed title on refusal
     setTitle(trimmed);
     renamed.current = true;
 
     if (await repository.rename(boardId, trimmed)) return;
 
     // A board seeded on a first visit has no stored record until autosave
     // fires, and rename() cannot touch what does not exist. Write one now —
     // otherwise the next autosave stores it under the default name and the
     // rename silently disappears. save() without a title keeps this one.
-    await repository.save(boardId, app.current?.store.toJSON() ?? EMPTY_BOARD, { title: trimmed });
+    if (await repository.save(boardId, app.current?.store.toJSON() ?? EMPTY_BOARD, { title: trimmed })) return;
+
+    // Neither write landed — the board is not this account's to rename. Put
+    // the stored name back rather than showing one that does not exist.
+    setTitle(title);
+    setDraft(title);
+    renamed.current = false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/BoardPage.jsx` around lines 66 - 76, Update the rename handler
around repository.rename and the fallback repository.save to track whether
either write succeeds; when both return false, restore the previously committed
title or surface the existing error state instead of retaining the optimistic
setTitle value. Preserve the current fallback save behavior for boards without
stored records.
src/routes/BoardListPage.jsx-43-51 (1)

43-51: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

mutate reports nothing when a mutation fails or throws.

error is set only by create. remove, leave, and rename route through mutate, which ignores the return value and has no catch. Two consequences:

  • A refused write returns false and the list refresh shows the board unchanged, with no explanation. ShareDialog sets an error for the same sharing.leave refusal, so the two surfaces behave differently.
  • If fn() or refresh() rejects, the rejection escapes the click handler unhandled, and the list keeps stale rows.

Consider having mutate accept a failure message and set error on false or on rejection.

♻️ Proposed shape
-  const mutate = async (fn) => {
-    setBusy(true);
-    try {
-      await fn();
-      await refresh();
-    } finally {
-      setBusy(false);
-    }
-  };
+  const mutate = async (fn, failure) => {
+    setBusy(true);
+    setError(null);
+    try {
+      if ((await fn()) === false) setError(failure);
+    } catch {
+      setError(failure);
+    } finally {
+      // The list is re-read either way: a refused write still leaves the
+      // truth on the server, and that is what should be on screen.
+      await refresh().catch(() => {});
+      setBusy(false);
+    }
+  };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/BoardListPage.jsx` around lines 43 - 51, Update the mutate
function so callers provide an operation-specific failure message, set error
when the mutation returns false or when fn or refresh rejects, and preserve the
busy-state cleanup in finally. Update remove, leave, and rename call sites to
supply appropriate messages, ensuring failed operations do not leave stale rows
or unhandled rejections.
src/styles/shell.css-103-106 (1)

103-106: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Strengthen the focus indicator on the account inputs.

Line 104 removes the browser outline. The replacement is a border colour change only. A colour-only change is hard to see, and it disappears for users with forced colours or reduced colour perception. These inputs are on the sign-in gate, so keyboard users need a clear focus state.

Add a visible ring instead of removing the outline entirely.

♻️ Proposed focus style
 .account-form input:focus {
-  outline: none;
+  outline: 2px solid var(--accent);
+  outline-offset: 1px;
   border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/styles/shell.css` around lines 103 - 106, The .account-form input:focus
rule removes the outline and relies solely on a border-color change for focus
indication, which fails accessibility requirements for keyboard users and those
with reduced color perception. Remove the outline: none declaration and instead
add a visible ring property (such as outline or box-shadow) that provides a
clear, non-color-dependent focus indicator alongside the border-color change.
This ensures the focus state remains visible under forced colors and for all
users navigating via keyboard.
test/node/supabase-repository.test.js-156-165 (1)

156-165: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This ordering assertion has no tie-break margin.

The assertion at Line 164 depends on first receiving a strictly later updated_at than second. The list() query orders by updated_at only, with no secondary sort key. If two saves land on the same timestamp, Postgres returns those rows in an arbitrary order and the test fails intermittently.

Make the claim explicit by asserting the timestamps, or separate the two saves so the stamps cannot collide.

💚 Proposed fix
     const ids = (await alice.repository.list()).map((b) => b.id);
+    const stamps = new Map((await alice.repository.list()).map((b) => [b.id, b.updatedAt]));
+    assert.ok(
+      stamps.get(first) > stamps.get(second),
+      'the two saves landed on the same updated_at, so the order is arbitrary',
+    );
     assert.ok(ids.indexOf(first) < ids.indexOf(second), 'updated_at was not restamped');

A cheaper alternative is a secondary sort key in list(), which also makes the production order stable.

As per path instructions: "flag assertions that could be flaky — fixed sleeps standing in for a real readiness condition, or probabilistic claims without margin."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/supabase-repository.test.js` around lines 156 - 165, The test
assertion in the save-moves-board-back-to-front test depends on two saves
receiving different updated_at timestamps without any guarantee of separation,
causing flaky failures when they land on the same timestamp. Either add a small
time delay between the alice.repository.save calls for the second save and the
subsequent save of first (so their timestamps cannot collide), or replace the
indexOf comparison assertion with an explicit timestamp assertion that directly
compares the updated_at values of the first and second boards from the list
result.

Source: Path instructions

src/platform/supabase-repository.js-59-72 (1)

59-72: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Return an empty list when auth.current() is null instead of marking all boards as not owned.

When auth.current() returns null before the account is populated, list() sets me to undefined. The comparison row.owner_id === me then evaluates to false for all rows, causing BoardListPage to show a "Leave" button for boards the caller actually owns. The save() method at line 145 already checks for null account explicitly (if (!ownerId) return false;), confirming this condition is reachable. Return an empty list when there is no account to prevent incorrect UI labels.

Proposed fix
      const me = auth.current()?.id;
+     if (!me) return [];
      return data.map((row) => ({ ...toSummary(row), owned: row.owner_id === me }));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/supabase-repository.js` around lines 59 - 72, Update the list()
method to check the result of auth.current() before mapping board rows; when no
account is present, return an empty list immediately. Preserve the existing
owned calculation and summary mapping for authenticated callers.
test/browser/auth.test.js-32-35 (1)

32-35: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

uniqueEmail can collide across runs.

The address is derived from process.pid and a counter that restarts at 0 on every run. The operating system reuses pids. If a later run gets a pid that an earlier run already used, the first generated address matches a registration row that the earlier run left behind. The registration test then fails against an existing account, and the failure looks like a bug in the sign-up path.

Add a per-run value that does not repeat. test/node/sync.test.js line 62 already uses Date.now() for the same reason.

🔧 Proposed fix
-  const uniqueEmail = () => `peerthink-${process.pid}-${counter++}`@example.com``;
+  const run = `${process.pid.toString(36)}${Date.now().toString(36)}`;
+  const uniqueEmail = () => `peerthink-${run}-${counter++}`@example.com``;
   let counter = 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/auth.test.js` around lines 32 - 35, Update the uniqueEmail
generator to include a per-run Date.now() value alongside the process ID and
counter, ensuring generated registration addresses remain unique even when
process IDs are reused. Keep the existing counter-based uniqueness within the
run.

Source: Path instructions

test/node/sync.test.js-194-206 (1)

194-206: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

This test leaves Alice's channel subscribed.

Every other test destroys both sides. This one destroys his.sync only, so hers.sync stays subscribed until after() calls removeAllChannels(). The comment at lines 100-102 states why an open realtime handle matters. Destroy hers.sync at the end of the test.

🔧 Proposed fix
     assert.equal(his.store.has('c1'), false, 'a destroyed sync was still listening');
+
+    await hers.sync.destroy();
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` around lines 194 - 206, Update the “destroy stops
sending, and stops receiving” test to destroy hers.sync after the assertion,
ensuring both sync instances created by join are cleaned up before the test
completes while preserving the existing assertion behavior.
test/browser/boards.test.js-353-354 (1)

353-354: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the flush result here, as line 106 does.

flush() returns whether the write was accepted. This call discards it. If the write is refused, the test continues and fails later at line 362 while waiting for the card to reach the other page. The reported failure then points at sharing instead of at the refused save.

🔧 Proposed fix
       await page.waitFor('Boolean(window.app.autosave)');
-      await page.eval('window.app.autosave.flush()');
+      assert.equal(await page.eval('window.app.autosave.flush()'), true, 'the write was refused');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/boards.test.js` around lines 353 - 354, Update the page.eval
call at line 354 that invokes window.app.autosave.flush() to capture and assert
its return value, matching the pattern used at line 106. The flush method
returns a boolean indicating whether the write was accepted, and currently this
result is discarded. Asserting the return value ensures the test fails
immediately if the save is refused instead of continuing and failing later with
a misleading error message at line 362.

Source: Path instructions

src/core/scheduler.js-37-55 (1)

37-55: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

throttle does not provide cancellation, so pending runs may execute after destroy().

The implementation returns only a trigger function. When destroy() sets stopped = true, the pending timer still fires. The callbacks in sync.js include guards (if (!next || stopped) return;) that prevent sending, but they do not prevent timer execution or queue mutation.

The test at lines 194-206 verifies that a destroyed receiver stops listening, but does not cover the case where a destroyed sender still has a queued throttle. Add a cancel() method to the returned trigger that calls clearTimeout(timer) and resets state, or verify that all callers handle a pending run after teardown.

♻️ Optional: expose a cancel handle
     throttle(fn, ms) {
       let timer = null;
       let queued = false;
 
       const run = () => {
         fn();
         timer = setTimeout(() => {
           timer = null;
           if (!queued) return;
           queued = false;
           run();
         }, ms);
       };
 
-      return () => {
+      const trigger = () => {
         if (timer) queued = true;
         else run();
       };
+      trigger.cancel = () => {
+        clearTimeout(timer);
+        timer = null;
+        queued = false;
+      };
+      return trigger;
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/scheduler.js` around lines 37 - 55, Update throttle in the scheduler
to expose cancellation on the returned trigger, with cancel clearing any pending
timer and resetting queued/timer state so no deferred run executes after
teardown. Update destroy() in the relevant receiver/sender flow to invoke this
cancel handle, preserving existing trigger behavior before destruction.
README.md-308-308 (1)

308-308: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove share links from "Not built yet".

This change ships share links. The Sharing section on lines 231-259 documents them, and supabase/migrations/20260804193000_board_invites.sql implements them.

📝 Proposed fix
-Share links and export.
+Export.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 308, Update the README feature-status list by removing
“Share links” from the “Not built yet” entry, while preserving the existing
Sharing documentation and other unfinished features.
supabase/migrations/20260804193000_board_invites.sql-136-136 (1)

136-136: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

order by 3 desc puts viewer above owner.

Column 3 is the role text. Descending text order is viewer, owner, editor, so the owner appears between the two other roles instead of first. Sort on the role's meaning rather than its spelling.

🐛 Proposed fix
-  order by 3 desc, 2 nulls last;
+  order by (case role when 'owner' then 0 when 'editor' then 1 else 2 end), email nulls last;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260804193000_board_invites.sql` at line 136, Update the
ORDER BY clause in the board invite query to sort the role column by semantic
priority, ensuring owner appears first, followed by the other roles in the
intended order, while preserving nulls-last behavior.
src/core/store.js-38-48 (1)

38-48: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

mergeOrder can put the same id in order twice.

kept filters incoming by object membership but does not deduplicate, and merged starts as a copy of kept. An order op whose array repeats an id therefore yields a repeated id in this.order.

The consequences outlast the message. all() returns the object twice, the renderer draws it twice, and toJSON() writes the duplicate into the saved doc. A later del removes only the first occurrence, because this.order.splice(this.order.indexOf(op.id), 1) on line 124 matches once, so order keeps an id with no object and all() then yields undefined.

Local order ops come from core/board.js and are well formed, but a remote one is applied straight from a peer broadcast in src/platform/sync.js (line 133).

🐛 Proposed fix
 export function mergeOrder(current, incoming, objects) {
-  const kept = incoming.filter((id) => objects.has(id));
-  const named = new Set(kept);
-  const merged = kept.slice();
+  const named = new Set();
+  // an id named twice is one position, not two: a duplicate in `order` is an
+  // object drawn twice and saved twice
+  const kept = incoming.filter((id) => objects.has(id) && !named.has(id) && named.add(id));
+  const merged = kept.slice();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/store.js` around lines 38 - 48, Update mergeOrder to deduplicate
incoming ids while retaining only ids present in objects, and build merged from
that unique sequence. Preserve the existing current-order insertion behavior for
valid ids so the resulting order contains each object id at most once.
supabase/migrations/20260804154000_board_broadcast.sql-20-28 (1)

20-28: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Allow viewers to publish presence only. board_broadcast_send rejects a viewer’s channel.track(...), so other clients omit that viewer from presenceState() and the member list. The viewer still subscribes and receives ops; a failed track does not change sync.ready from SUBSCRIBED. Add an INSERT policy for extension = 'presence' when board_role(...) is not null, and restrict the existing send policy to extension = 'broadcast'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260804154000_board_broadcast.sql` around lines 20 - 28,
Create a new INSERT policy on realtime.messages that allows authenticated users
with any valid board_role to insert presence messages by checking extension =
'presence', and update the existing board_broadcast_send policy to add a filter
for extension = 'broadcast' so that it only restricts broadcast message inserts
to users with 'owner' or 'editor' roles while allowing viewers to publish
presence.
src/app.js-195-195 (1)

195-195: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

commands.fit() discards a pan or zoom made during the load.

The canvas is interactive from the first frame, and the load is now a network round trip. If the user pans or zooms in that window, this call resets the viewport when the snapshot lands. That is the same class of loss the early op buffer was added to prevent, applied to the viewport instead of the document. Consider skipping the fit if the viewport has already been moved by the user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app.js` at line 195, Update the load completion flow around
commands.fit() to avoid resetting the viewport when the user has already panned
or zoomed during the network request. Track or reuse the existing
viewport-interaction state and only call fit for an untouched viewport,
preserving user camera changes when the snapshot arrives.
test/db/apply.js-14-14 (1)

14-14: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the pg import.

test/** permits only Vite from npm, and only in test/helpers/vite.js. A devDependency declaration does not make pg permitted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db/apply.js` at line 14, Remove the import statement for pg from the top
of the test file. The pg module import violates the constraint that only Vite is
permitted as an npm import in test files outside of test/helpers/vite.js.

Source: Path instructions

test/browser/app.test.js-26-26 (1)

26-26: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The doc comment names a binding the body never receives.

The body is called with { second, host, elements, hydrated }. There is no app.

📝 Proposed change
-   * `body` is evaluated with `second`, `host`, `hydrated` and `app` in scope.
+   * `body` is evaluated with `second`, `host`, `elements` and `hydrated` in scope.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/app.test.js` at line 26, Update the doc comment describing the
body callback scope to list the actual bindings passed by the call—second, host,
elements, and hydrated—and remove the nonexistent app binding.
test/db/rls.test.js-12-12 (1)

12-12: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a comment explaining the pg dependency exception in test/db/, or update the test layer guideline.

The coding guidelines restrict test/* to Node built-ins with Vite only in test/helpers/vite.js. Both test/db/rls.test.js and test/db/apply.js import pg, which violates this constraint.

The practical justification is present: row-level security testing requires transaction control (begin/commit/rollback), parameterized queries ($1, $2), and per-transaction role switching—all of which need a full PostgreSQL client. Raw wire protocol, HTTP endpoints, or CLI invocation via child_process would lose transaction boundaries or programmatic control.

Either add a code comment documenting why pg is necessary despite the guideline, or clarify the guideline to permit database clients in test infrastructure code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db/rls.test.js` at line 12, Add a concise comment near the pg import in
the database test infrastructure explaining that pg is intentionally required
for transaction control, parameterized queries, and per-transaction role
switching, despite the test dependency guideline; apply the same documentation
to the related apply.js usage if needed.

Source: Path instructions

🧹 Nitpick comments (20)
test/node/sharing.test.js (1)

239-242: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename this test to match what it builds.

The test constructs a sharing service, not a repository, and it reuses alice's authenticated client — so what it pins is the client-side auth.current() guard inside createSharing, not a database policy. A name such as 'sharing with no account in hand is refused' states that.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sharing.test.js` around lines 239 - 242, Rename the test case
around createSharing to describe the client-side no-account guard rather than a
repository policy, reflecting that it uses alice.client with auth.current()
returning null and verifies sharing is refused.
src/routes/BoardPage.jsx (2)

105-114: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Move focus into the share dialog and return it on close.

ShareDialog renders role="dialog" but nothing moves focus into it. Keyboard focus stays on the Share button behind the backdrop, and Tab continues through the board underneath. On close, focus is not restored either. Add an initial focus target inside the dialog and a focus trap, then return focus to the Share button when showShare becomes false.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/BoardPage.jsx` around lines 105 - 114, Update the ShareDialog
integration in BoardPage to provide an initial focus target and trap keyboard
focus within the dialog while showShare is true; when the dialog closes, restore
focus to the Share button that opened it. Use refs and the existing showShare
lifecycle, preserving the current onClose and onLeave behavior.

27-44: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider handling a rejected title read.

titleOf awaits repository.load, which now performs a network call. createSupabaseRepository.load converts query errors into null, but a transport-level failure still rejects the promise. This effect has no rejection handler, so such a failure produces an unhandled rejection and the field keeps DEFAULT_TITLE with no signal.

♻️ Proposed fix to swallow the read failure explicitly
-    titleOf(boardId).then((current) => {
-      // Two ways this result is no longer wanted: the route moved on, or the
-      // user renamed the board while the read was still in flight. Applying it
-      // in either case puts a stale title back on screen — invisible against
-      // Web Storage, routine once the repository is a network away.
-      if (!live || renamed.current) return;
-      setTitle(current);
-      setDraft(current);
-    });
+    titleOf(boardId)
+      .then((current) => {
+        // Two ways this result is no longer wanted: the route moved on, or the
+        // user renamed the board while the read was still in flight. Applying it
+        // in either case puts a stale title back on screen — invisible against
+        // Web Storage, routine once the repository is a network away.
+        if (!live || renamed.current) return;
+        setTitle(current);
+        setDraft(current);
+      })
+      // A title that could not be read leaves the default in place; there is
+      // nothing to recover and nothing to report.
+      .catch(() => {});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/BoardPage.jsx` around lines 27 - 44, Add explicit rejection
handling to the titleOf promise in the useEffect for BoardPage, swallowing
transport-level read failures without updating the title or draft and preventing
an unhandled rejection. Preserve the existing live and renamed guards for
successful results.
test/node/supabase-repository.test.js (1)

233-245: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The first doc block describes a different test.

Two JSDoc blocks stack before describe('write authority'). The first block describes two tabs racing one new board, which is the test at Line 339, not this suite. Move it to that test so each block documents what follows it.

♻️ Proposed fix
-  /**
-   * Two tabs, one new board, no row yet: both updates find nothing and both
-   * insert. One loses on the primary key and has to notice that the row it
-   * wanted now exists rather than reporting a failed save.
-   */
   /**
    * The half of write authority that election cannot provide.

Then place the removed block directly above the test at Line 339.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/supabase-repository.test.js` around lines 233 - 245, Move the first
JSDoc block describing two tabs racing to insert a new board from above
describe('write authority') to directly above the corresponding test near the
two-tab insertion race, while leaving the write-authority documentation
immediately before describe('write authority').
test/helpers/supabase.js (1)

28-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

On Windows this call fails as "no stack" instead of finding one.

execFileSync without a shell cannot run npx, because npx is a .cmd shim on Windows. The spawn error is caught at Line 38, so a running local stack is reported as absent and the Supabase suites skip silently. test/helpers/vite.js already handles Windows paths, so the project appears to support that platform.

If Windows support matters here, resolve the platform-specific binary name.

♻️ Proposed platform-aware command
-    const out = execFileSync('npx', ['supabase', 'status', '-o', 'json'], {
+    const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
+    const out = execFileSync(npx, ['supabase', 'status', '-o', 'json'], {
       cwd: ROOT,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/helpers/supabase.js` around lines 28 - 33, Update the Supabase status
invocation in the helper’s stack-detection function to select the
Windows-compatible npx command name when running on Windows, while retaining the
existing command for other platforms. Keep the current execFileSync arguments
and error handling unchanged.
.github/workflows/ci.yml (1)

47-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the Supabase CLI to an exact version in devDependencies.

The current devDependency declaration uses "^2.111.0" (caret range), which resolves to any version from 2.111.0 to 2.999.x at run time. Between CI runs, npx supabase start may pull different CLI versions, causing unpredictable changes to the stack version and migration behavior. Change the dependency to an exact version pin (e.g., "2.111.0").

Additionally, add set -o pipefail to this step. Without it, a failure in npx supabase status is masked by the jq pipe, leaving DATABASE_URL empty instead of failing the step.

♻️ Proposed step hardening
       - name: Start Supabase
         run: |
+          set -euo pipefail
           npx supabase start
           echo "DATABASE_URL=$(npx supabase status -o json | jq -r .DB_URL)" >> "$GITHUB_ENV"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 47 - 50, Pin the Supabase CLI
devDependency to an exact version such as 2.111.0 instead of a caret range, and
update the “Start Supabase” workflow step to enable pipefail before the
status-to-jq pipeline so failures propagate and prevent writing an empty
DATABASE_URL.
test/browser/auth.test.js (1)

203-213: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The comment does not describe what the test does.

The comment says the fresh guest "wants their own account instead". The test then submits the same email and password and asserts that .account-email shows that address. That is a sign-in to the existing account, which matches the test title. Correct the comment so it states that the second guest signs in to an account that already exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/auth.test.js` around lines 203 - 213, Update the comment at the
start of this test block to accurately describe the behavior: instead of saying
the fresh guest "wants their own account instead," clarify that the second guest
is signing in to an account that already exists using the same email and
password. Ensure the comment aligns with the test title and the
submitAccountForm call that uses the existing email and password credentials.
test/node/autosave.test.js (1)

80-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

gated duplicates harness.

gated repeats the store, scheduler, recording repository, and createAutosave call from harness at lines 10-23. The only difference is the canWrite argument. Give harness an optional canWrite parameter and call it from both places.

♻️ Proposed refactor
-function harness() {
+function harness(canWrite) {
   const store = new Store();
   const scheduler = createManualScheduler();
   const saved = [];
   const repository = {
     load: async () => null,
     async save(boardId, board) {
       saved.push({ boardId, board });
       return true;
     },
   };
-  const autosave = createAutosave({ store, repository, boardId: 'alpha', scheduler });
+  const autosave = createAutosave({ store, repository, boardId: 'alpha', scheduler, canWrite });
   return { store, scheduler, saved, autosave };
 }

Then replace the gated body with const gated = (canWrite) => harness(canWrite);.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/autosave.test.js` around lines 80 - 93, Add an optional canWrite
parameter to the harness function defined at lines 10-23, and update the
createAutosave call within harness to pass this parameter. Then replace the
gated function body with a simple delegation that calls harness(canWrite),
removing the duplicated store, scheduler, repository, and createAutosave logic.
src/components/ShareDialog.jsx (1)

107-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add aria-modal and initial focus.

The dialog closes on Escape and on the backdrop, but it does not tell assistive technology that the content behind it is inert, and it does not move focus into itself. A screen reader user keeps hearing the board list, and a keyboard user must tab in from wherever focus happened to be.

♻️ Proposed change
-      <div className="share" role="dialog" aria-label={`Share ${title}`}>
+      <div className="share" role="dialog" aria-modal="true" aria-label={`Share ${title}`}>
         <header className="share-header">
           <h2>Share “{title}”</h2>
-          <button type="button" data-action="close-share" onClick={onClose} aria-label="Close">✕</button>
+          <button
+            type="button"
+            data-action="close-share"
+            onClick={onClose}
+            aria-label="Close"
+            // the first thing a keyboard user reaches, and the safe default
+            autoFocus
+          >✕</button>

A focus trap is a larger change and can follow separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ShareDialog.jsx` around lines 107 - 111, Add the aria-modal
attribute to the main dialog container (the div with className="share" and
role="dialog") to inform assistive technology that content behind the dialog is
inert. Additionally, set initial focus to the close button (the button with
data-action="close-share") when the dialog opens so keyboard and screen reader
users immediately interact with the dialog rather than the underlying page
content.
supabase/migrations/20260804101204_boards.sql (2)

147-149: 🚀 Performance & Scalability | 🔵 Trivial

list() cost grows with the whole boards table, not with the caller's boards.

list() sends no where clause, so boards_select decides the row set. The owner_id disjunct is served by boards_owner_updated_idx, but every row that fails it reaches public.board_role(id), which runs two lookups per row. As the table grows across all users, one person's board list pays for rows they will never see.

If the list query becomes slow, add an explicit membership predicate on the client side, for example owner_id = <uid> unioned with an in list from board_members, so the index does the filtering and the policy only confirms it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260804101204_boards.sql` around lines 147 - 149, The
boards_select policy evaluates public.board_role(id) for every row that does not
match owner_id, causing the cost to grow with the entire boards table rather
than just the caller's accessible boards. Update the list() function or query to
add explicit membership filtering using a WHERE clause that checks owner_id =
authenticated user's uid OR id in a subquery joining board_members by user_id,
so the index handles the row filtering before the policy evaluates access. This
ensures expensive lookups in public.board_role(id) only run on rows that have
already been narrowed to the caller's boards.

93-130: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Security Misconfiguration (CWE-426): Untrusted Search Path

Exploitability: Theoretical

Pin search_path on all board trigger functions.

Set search_path = pg_catalog, public, pg_temp on touch_updated_at(), freeze_board_owner(), and bump_board_version(). This prevents caller-selected search paths from affecting unqualified function and operator lookups. The migrations do not grant authenticated schema-creation privileges, so this remains defense-in-depth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260804101204_boards.sql` around lines 93 - 130, Set the
function-level search_path to pg_catalog, public, pg_temp on touch_updated_at(),
freeze_board_owner(), and bump_board_version(). Preserve each trigger function’s
existing behavior while pinning lookup resolution against caller-controlled
search paths.
test/db/apply.js (1)

46-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Protect the migration error from a failing rollback.

If client.query('rollback') rejects, that rejection replaces the original migration error. The failing migration name is then lost, which defeats the stated purpose of the per-file transaction. Swallow the rollback failure.

♻️ Proposed change
       } catch (error) {
-        await client.query('rollback');
+        await client.query('rollback').catch(() => {});
         error.message = `${name}: ${error.message}`;
         throw error;
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db/apply.js` around lines 46 - 50, The rollback call inside the catch
block can reject and replace the original migration error. Wrap the
client.query('rollback') call in its own try-catch block to swallow any rollback
failures, then continue with throwing the original error that already has the
migration name prepended in its message. This preserves the original migration
error details even when rollback itself fails.
test/coverage.js (1)

29-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider failing when a SUPABASE_ONLY entry no longer exists.

The list holds hardcoded paths. If a file is renamed or removed, the stale entry stays silent and the intent is lost. report() could assert that every unmeasured path exists on disk, or at least appears in rows. That keeps the gate honest as the file set changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/coverage.js` around lines 29 - 41, Update the report() function to
validate that every entry in the SUPABASE_ONLY array either exists on disk or
appears in the rows collection. If any SUPABASE_ONLY entry cannot be found in
either location, fail with a clear error message indicating which stale paths
need to be removed or updated. This ensures the list stays synchronized as files
are renamed or removed during development.
src/app.js (1)

108-108: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Make hydrate() refuse a second call.

hydrate is part of the public app surface and has no re-entry guard. A second call loads the board again, then builds a second autosave and a second sync. The first autosave is never stopped and app.autosave is overwritten, so destroy() releases only the last one. createBoardSync also documents that a second channel for the same topic throws. A single flag keeps the lifecycle honest.

♻️ Proposed guard
   let autosave = null;
   let sync = null;
   let cursors = null;
   let destroyed = false;
+  let hydrating = null;
-  async function hydrate() {
+  function hydrate() {
+    // One board, one load: a second call would build a second autosave and a
+    // second channel, and only the last of each would be torn down.
+    hydrating ??= load();
+    return hydrating;
+  }
+
+  async function load() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app.js` at line 108, Add a re-entry guard flag to the hydrate function to
prevent it from being called more than once. At the start of hydrate, check if a
flag (such as hydrated or hasHydrated) has already been set to true and return
early if so; otherwise set the flag and proceed with the existing hydrate logic.
This prevents duplicate board loads, prevents creating multiple autosave and
sync instances, and ensures app.autosave is not overwritten and destroy()
releases the correct instance.
test/browser/shell.test.js (1)

110-125: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test rarely observes the state it names.

The assertion races the board read. If the read has already resolved, seen.resolved is true, so seen.empty && !seen.resolved is false whatever the empty state was. The test then passes without ever reaching the loading state, and the comment above it describes an invariant that stays unverified. The test cannot fail spuriously, but it also cannot catch a regression.

Hold the read open instead of racing it — inject a repository whose list() resolves on command, assert [data-loading] is present and [data-empty] is absent, then resolve and assert the settled empty state.

As per path instructions: "flag assertions that could be flaky — fixed sleeps standing in for a real readiness condition, or probabilistic claims without margin."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/shell.test.js` around lines 110 - 125, The test 'says nothing
about being empty until the boards have been read' races the board read instead
of holding it open, so it frequently passes without observing the loading state
it claims to verify. Replace the racing approach by injecting a repository whose
list() method resolves on command rather than immediately, then assert that
[data-loading] is present and [data-empty] is absent while the read is in
flight, resolve the injected list() promise, and finally assert the settled
empty state using showsEmptyState(). This ensures the loading-state invariant
described in the comment is actually tested rather than skipped.

Source: Path instructions

src/shell/sync.js (1)

9-24: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

The presence label is captured once and does not follow a later sign-in.

auth.current() runs when createSync is called, which is during hydrate(). The PR lets an anonymous user attach an email afterwards. If the user registers while a board is open, presence keeps reporting Guest until the board is rejoined. Passing a function instead of a resolved string, or re-tracking on an auth change, would keep the label current.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shell/sync.js` around lines 9 - 24, The identity.label in createSync is
evaluated once when createSync is called, capturing the user's auth state at
that moment. If a user later signs in after initially being a guest, the label
never updates. Update the identity.label to use a function that dynamically
returns the current auth state (passing a function like () =>
auth.current()?.email ?? 'Guest' instead of evaluating auth.current()
immediately), or alternatively set up a listener that re-tracks the presence
when the user's auth state changes.
test/browser/app.test.js (1)

174-183: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Two tests re-inline the whole withSecondApp scaffold.

Lines 174-192 and lines 230-248 each rebuild the host element, the markup and the elements map that withSecondApp already builds. They opt out only because they need destroy() at a chosen moment rather than in the finally. An option on the helper — destroy: false, leaving teardown to the body — would let both tests use it and remove about forty duplicated lines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/app.test.js` around lines 174 - 183, The two test blocks around
lines 174-183 and lines 230-248 are duplicating the host element creation,
markup setup, and elements map initialization that the withSecondApp helper
already provides. Add a configuration option to the withSecondApp helper (such
as destroy: false or manualDestroy: true) to defer the destroy() call from the
finally block, allowing callers to invoke destroy() manually at their chosen
moment. Then refactor both duplicated test blocks to use withSecondApp with this
new option enabled, and have each test explicitly call destroy() when ready
instead of relying on the finally cleanup. This removes the forty lines of
duplicated scaffold code while preserving the tests' control over destroy
timing.
src/components/BoardCanvas.jsx (1)

48-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

A failed load leaves no signal to the user.

The catch discards the error. The canvas stays empty, which is safe: hydrate rejects before it seeds or builds autosave, so nothing overwrites the stored board. But the user sees an empty board and cannot tell it apart from a new one, and has no way to retry other than a reload. Consider surfacing the failure through onReady or route state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/BoardCanvas.jsx` around lines 48 - 50, Update the error
handling in the app.hydrate().catch() block to communicate the failure to the
user instead of silently discarding it. Use either the onReady callback or route
state to surface the error, allowing the user to understand that the board
failed to load and potentially retry without needing a full page reload.
Preserve the existing safe behavior of not seeding or overwriting stored data
when hydrate rejects.
src/components/AccountMenu.jsx (1)

34-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add aria-expanded to the disclosure button.

The button toggles the popover, but it does not report its state. A screen reader announces it as a plain button, so the user cannot tell whether the account form is open.

♻️ Proposed change
-      <button type="button" data-action="save-account" onClick={() => setOpen((was) => !was)}>
+      <button
+        type="button"
+        data-action="save-account"
+        aria-expanded={open}
+        onClick={() => setOpen((was) => !was)}
+      >
         Save your boards
       </button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/AccountMenu.jsx` around lines 34 - 45, Update the disclosure
button in the AccountMenu component to include aria-expanded bound to the open
state, so assistive technologies can identify whether the account popover is
expanded or collapsed.
test/node/auth.test.js (1)

291-304: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The assertion does not test the stated behavior.

The test name states that a client without storage keeps its session in memory. Both assertions only check that client.auth is truthy, which is true for any constructed client. The test passes even if persistSession is wired backwards.

Assert the observable difference instead: with a storage adapter supplied, a setItem call reaches the Map after a session is written; with none supplied, the Map stays empty. If that is not reachable without a live project, assert the configuration that createSupabaseClient produces from each branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/auth.test.js` around lines 291 - 304, The test around
createSupabaseClient must verify session persistence behavior rather than merely
checking that auth exists. Exercise a session write and assert that the supplied
storage adapter’s Map receives it, while the no-storage branch does not write to
that Map; if session writing requires unavailable network access, assert the
corresponding persistSession configuration produced by each client branch.

Comment thread src/app.js
Comment thread src/components/AccountForm.jsx
Comment thread src/components/ShareDialog.jsx
Comment thread src/platform/auth.js
Comment thread src/platform/sharing.js
Comment thread src/platform/sync.js
Comment thread test/db/apply.js Outdated
Comment thread test/db/stub-auth.sql Outdated
Comment thread test/node/sync.test.js Outdated
Comment thread test/node/sync.test.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/routes/BoardPage.jsx (1)

50-76: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

commit() has no failure path for a rejected rename or save.

repository.rename() and repository.save() return false on a refused write, and the code handles that. Against Supabase they can also reject on a network failure. commit() runs from onBlur, so a rejection escapes as an unhandled rejection.

Line 66 also sets the committed title before the write is confirmed. If both the rename and the save fail, the header shows a name that was never persisted, and renamed.current stays true, so the reload path at line 36 will not correct it.

🛡️ Proposed fix
     setTitle(trimmed);
     renamed.current = true;
 
-    if (await repository.rename(boardId, trimmed)) return;
-
-    // A board seeded on a first visit has no stored record until autosave
-    // fires, and rename() cannot touch what does not exist. Write one now —
-    // otherwise the next autosave stores it under the default name and the
-    // rename silently disappears. save() without a title keeps this one.
-    await repository.save(boardId, app.current?.store.toJSON() ?? EMPTY_BOARD, { title: trimmed });
+    try {
+      if (await repository.rename(boardId, trimmed)) return;
+
+      // A board seeded on a first visit has no stored record until autosave
+      // fires, and rename() cannot touch what does not exist. Write one now —
+      // otherwise the next autosave stores it under the default name and the
+      // rename silently disappears. save() without a title keeps this one.
+      if (await repository.save(boardId, app.current?.store.toJSON() ?? EMPTY_BOARD, { title: trimmed })) return;
+    } catch {
+      // fall through: a rename that did not land must not stay on screen
+    }
+
+    setTitle(title);
+    setDraft(title);
+    renamed.current = false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/BoardPage.jsx` around lines 50 - 76, Update commit() to handle
rejected promises from repository.rename() and repository.save() with a failure
path that restores the persisted title state, resets renamed.current, and avoids
unhandled onBlur rejections. Only keep the new title committed after the write
succeeds; ensure refused writes and thrown network errors both leave the header
and reload behavior consistent.
♻️ Duplicate comments (4)
src/components/AccountForm.jsx (1)

21-42: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reset busy in a finally block and handle a rejected auth call.

submit() still awaits auth.register() and auth.signIn() without a catch. A rejection skips line 30, so the submit button stays disabled and no error appears.

🛡️ Proposed fix
   const submit = async (event) => {
     event.preventDefault();
     setBusy(true);
     setError(null);
 
-    const result = registering
-      ? await auth.register({ email, password })
-      : await auth.signIn({ email, password });
-
-    setBusy(false);
+    let result;
+    try {
+      result = registering
+        ? await auth.register({ email, password })
+        : await auth.signIn({ email, password });
+    } catch {
+      setError('Something went wrong. Try again.');
+      return;
+    } finally {
+      setBusy(false);
+    }
+
     if (!result.ok) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/AccountForm.jsx` around lines 21 - 42, Update the submit
function to wrap the auth.register/auth.signIn await and result handling in
try/catch/finally, display a suitable error when the auth call rejects, and
always reset busy in finally so rejected requests cannot leave the form
disabled.

Source: Linters/SAST tools

test/node/sync.test.js (2)

81-88: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Make waitFor throw on timeout.

waitFor still returns false when the deadline passes, and lines 138, 287, 335, 355 and 357 discard the result. At line 138 a timeout means Bob never received the op, and the assertions at lines 145-146 then pass against an empty store. Throw inside the helper so every call site fails loudly.

🔧 Proposed fix
   const waitFor = async (predicate, label, timeout = 5000) => {
     const deadline = Date.now() + timeout;
     while (Date.now() < deadline) {
       if (predicate()) return true;
       await new Promise((r) => setTimeout(r, 20));
     }
-    return false;
+    throw new Error(`timed out after ${timeout}ms waiting for ${label}`);
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` around lines 81 - 88, Update the waitFor helper to
throw an error containing the provided label when the timeout expires instead of
returning false, while preserving its successful true result so all existing
call sites fail loudly on unmet conditions.

Source: Path instructions


90-90: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

settle() is a fixed sleep behind every negative assertion.

Lines 143, 203, 224 and 245 wait 250 ms and then claim a message did not arrive. On a loaded runner the message can still be in flight, so the assertion proves nothing and the suite stays green while echo suppression, destroy() or the channel policy is broken.

Anchor each negative claim to a positive readiness condition: send a marker op after the op under test, wait for the marker to arrive, then assert the forbidden op is absent.

As per path instructions: "flag assertions that could be flaky — fixed sleeps standing in for a real readiness condition".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` at line 90, The settle() function provides only a
fixed 250ms sleep before negative assertions instead of verifying actual
readiness, causing flaky tests on loaded runners. Replace the fixed-sleep
pattern at each negative assertion location (lines 143, 203, 224, and 245) with
a marker-based readiness check: after the operation under test, send a marker
operation, await its arrival as a positive condition confirming in-flight
messages have completed, then assert the forbidden operation is absent.

Source: Path instructions

src/components/ShareDialog.jsx (1)

33-43: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Give the initial load a failure path.

refresh() still has no rejection handler. Promise.all rejects if sharing.invite or sharing.people rejects. people then stays null, so the dialog shows "Loading…" indefinitely and the rejection is unhandled.

🛡️ Proposed fix
   useEffect(() => {
     let live = true;
-    refresh().then(({ link, members }) => {
-      if (!live) return;
-      setInvite(link);
-      setPeople(members);
-    });
+    refresh().then(
+      ({ link, members }) => {
+        if (!live) return;
+        setInvite(link);
+        setPeople(members);
+      },
+      () => {
+        if (!live) return;
+        setError('Could not load sharing for this board.');
+      },
+    );
     return () => {
       live = false;
     };
   }, [refresh]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ShareDialog.jsx` around lines 33 - 43, Update the initial
loading effect around refresh in ShareDialog so rejected refresh promises are
handled: preserve the live guard, set an appropriate non-loading error state or
fallback when loading fails, and prevent the rejection from remaining unhandled.
Keep successful link and member updates unchanged.
🧹 Nitpick comments (2)
test/node/autosave.test.js (1)

80-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

gated() duplicates harness().

gated(canWrite) repeats the store, scheduler, saved array and repository from harness() at lines 10-23. Only canWrite differs. Give harness() an optional canWrite parameter and call it from both places.

♻️ Proposed refactor
-function harness() {
+function harness({ canWrite } = {}) {
   const store = new Store();
   const scheduler = createManualScheduler();
   const saved = [];
   const repository = {
     load: async () => null,
     async save(boardId, board) {
       saved.push({ boardId, board });
       return true;
     },
   };
-  const autosave = createAutosave({ store, repository, boardId: 'alpha', scheduler });
+  const autosave = createAutosave({ store, repository, boardId: 'alpha', scheduler, canWrite });
   return { store, scheduler, saved, autosave };
 }

gated then becomes const gated = (canWrite) => harness({ canWrite });.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/autosave.test.js` around lines 80 - 93, Refactor the test setup so
the existing harness function accepts an optional canWrite parameter and passes
it to createAutosave. Replace the duplicated setup inside gated with a call to
harness({ canWrite }), preserving the existing defaults and returned test
fixtures.
src/platform/sync.js (1)

80-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make now a required injectable dependency.

now = () => Date.now() defaults to a global in a browser adapter. This violates the requirement that every dependency arrive via factory argument. Remove the default and update call sites in src/shell/sync.js and test files to pass now explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/sync.js` at line 80, Make the now dependency required in the
sync factory by removing its Date.now default, then update all callers in the
sync shell and tests to pass an explicit now implementation. Preserve existing
timing behavior by using a Date.now-based function at production call sites and
controlled functions in tests.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 306-308: Update the README.md “Not built yet” section to remove
“Share links” and leave only “export” listed, preserving the existing
documentation that describes link-based sharing as available.

In `@src/platform/supabase-repository.js`:
- Around line 80-90: Update load() to delete the entry from versions when the
query confirms the board row is absent, while preserving the entry for query
errors or invalid data. Update remove() to request the deleted row and clear
versions only when that specific row was successfully deleted; retain the
existing version guard after errors or unauthorized zero-row deletions.

In `@src/platform/sync.js`:
- Around line 103-110: Update the flush callback in scheduler.throttle so
operation batches are removed from queue only after channel.send confirms
delivery with acknowledgements enabled. Handle “error” and “timed out” outcomes
by restoring the unsent ops to queue for retry after reconnection, while
preserving the stopped and empty-queue guards.

In `@src/routes/BoardListPage.jsx`:
- Around line 25-51: Handle rejected repository operations in the BoardListPage
loading and mutation flows: catch the initial repository.list() failure in the
useEffect and set the existing error message so boards no longer remains null or
produces an unhandled rejection; update mutate to catch failures from fn() or
refresh(), set error, and keep the rejection from escaping click handlers; clear
error after a successful mutation, and apply the same rejection handling to the
create handler’s repository.save call.

In `@test/helpers/supabase.js`:
- Around line 28-33: Add shell: true to the options object passed by the
execFileSync call in the Supabase status helper so npx resolves through npx.cmd
on Windows; preserve the existing command, working directory, output handling,
timeout, and catch behavior.

In `@test/node/sharing.test.js`:
- Around line 157-164: Update the “a member cannot remove anyone else” test to
add Carol as a board member before Bob’s removal attempt, then have Bob target
Carol’s member ID instead of alice.id. Assert that the board’s people() result
still includes Carol after the attempted removal, ensuring the unauthorized
deletion targets an actual board_members row.
- Line 4: Update test/node/sharing.test.js and all other test/** files with
direct Supabase npm imports to use the permitted createSupabaseClient export
from src/platform/supabase.js. In the member-removal test, create or add a
second non-owner member and target that member’s board_members row, preserving
the test’s authorization assertions while ensuring it exercises removal of a
real member.

In `@test/node/sync.test.js`:
- Around line 180-188: Replace the deliveries <= 2 assertion in the burst test
with a stable throttle-owned check: count outbound messages produced by the
throttle and assert fewer than the three applied operations, or assert
deliveries < 3 if that is the available observable. Update the assertion message
to describe the invariant as fewer messages than ops, avoiding an exact
wire-delivery ceiling.

---

Outside diff comments:
In `@src/routes/BoardPage.jsx`:
- Around line 50-76: Update commit() to handle rejected promises from
repository.rename() and repository.save() with a failure path that restores the
persisted title state, resets renamed.current, and avoids unhandled onBlur
rejections. Only keep the new title committed after the write succeeds; ensure
refused writes and thrown network errors both leave the header and reload
behavior consistent.

---

Duplicate comments:
In `@src/components/AccountForm.jsx`:
- Around line 21-42: Update the submit function to wrap the
auth.register/auth.signIn await and result handling in try/catch/finally,
display a suitable error when the auth call rejects, and always reset busy in
finally so rejected requests cannot leave the form disabled.

In `@src/components/ShareDialog.jsx`:
- Around line 33-43: Update the initial loading effect around refresh in
ShareDialog so rejected refresh promises are handled: preserve the live guard,
set an appropriate non-loading error state or fallback when loading fails, and
prevent the rejection from remaining unhandled. Keep successful link and member
updates unchanged.

In `@test/node/sync.test.js`:
- Around line 81-88: Update the waitFor helper to throw an error containing the
provided label when the timeout expires instead of returning false, while
preserving its successful true result so all existing call sites fail loudly on
unmet conditions.
- Line 90: The settle() function provides only a fixed 250ms sleep before
negative assertions instead of verifying actual readiness, causing flaky tests
on loaded runners. Replace the fixed-sleep pattern at each negative assertion
location (lines 143, 203, 224, and 245) with a marker-based readiness check:
after the operation under test, send a marker operation, await its arrival as a
positive condition confirming in-flight messages have completed, then assert the
forbidden operation is absent.

---

Nitpick comments:
In `@src/platform/sync.js`:
- Line 80: Make the now dependency required in the sync factory by removing its
Date.now default, then update all callers in the sync shell and tests to pass an
explicit now implementation. Preserve existing timing behavior by using a
Date.now-based function at production call sites and controlled functions in
tests.

In `@test/node/autosave.test.js`:
- Around line 80-93: Refactor the test setup so the existing harness function
accepts an optional canWrite parameter and passes it to createAutosave. Replace
the duplicated setup inside gated with a call to harness({ canWrite }),
preserving the existing defaults and returned test fixtures.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5933e4c1-8d17-41db-a9b6-b605e25e44cd

📥 Commits

Reviewing files that changed from the base of the PR and between a553da6 and fc8de56.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (58)
  • .github/workflows/ci.yml
  • .gitignore
  • README.md
  • package.json
  • src/App.jsx
  • src/app.js
  • src/components/AccountForm.jsx
  • src/components/AccountMenu.jsx
  • src/components/BoardCanvas.jsx
  • src/components/ShareDialog.jsx
  • src/core/autosave.js
  • src/core/scheduler.js
  • src/core/store.js
  • src/platform/auth.js
  • src/platform/cursors.js
  • src/platform/sharing.js
  • src/platform/storage.js
  • src/platform/supabase-repository.js
  • src/platform/supabase.js
  • src/platform/sync.js
  • src/routes/BoardListPage.jsx
  • src/routes/BoardPage.jsx
  • src/routes/JoinPage.jsx
  • src/shell/RequireAccount.jsx
  • src/shell/auth.js
  • src/shell/sharing.js
  • src/shell/storage.js
  • src/shell/sync.js
  • src/shell/web-storage.js
  • src/styles/canvas.css
  • src/styles/shell.css
  • supabase/.gitignore
  • supabase/config.toml
  • supabase/migrations/20260804101204_boards.sql
  • supabase/migrations/20260804154000_board_broadcast.sql
  • supabase/migrations/20260804171500_board_version.sql
  • supabase/migrations/20260804193000_board_invites.sql
  • test/browser/app.test.js
  • test/browser/auth.test.js
  • test/browser/boards.test.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/db/apply.js
  • test/db/rls.test.js
  • test/db/stub-auth.sql
  • test/helpers/browser.js
  • test/helpers/cdp.js
  • test/helpers/supabase.js
  • test/helpers/vite.js
  • test/node/auth.test.js
  • test/node/autosave.test.js
  • test/node/scheduler.test.js
  • test/node/sharing.test.js
  • test/node/storage.test.js
  • test/node/store.test.js
  • test/node/supabase-repository.test.js
  • test/node/sync.test.js
  • test/run.js
🚧 Files skipped from review as they are similar to previous changes (43)
  • .github/workflows/ci.yml
  • supabase/migrations/20260804154000_board_broadcast.sql
  • src/shell/sharing.js
  • src/routes/JoinPage.jsx
  • src/components/AccountMenu.jsx
  • src/shell/auth.js
  • src/shell/web-storage.js
  • src/shell/sync.js
  • src/components/BoardCanvas.jsx
  • src/App.jsx
  • .gitignore
  • src/shell/RequireAccount.jsx
  • src/platform/auth.js
  • supabase/config.toml
  • src/core/autosave.js
  • src/core/store.js
  • src/platform/sharing.js
  • src/platform/cursors.js
  • src/platform/storage.js
  • src/styles/shell.css
  • supabase/.gitignore
  • src/shell/storage.js
  • src/styles/canvas.css
  • test/helpers/cdp.js
  • package.json
  • supabase/migrations/20260804101204_boards.sql
  • test/browser/shell.test.js
  • src/platform/supabase.js
  • test/node/scheduler.test.js
  • test/helpers/vite.js
  • src/app.js
  • test/node/storage.test.js
  • test/node/auth.test.js
  • test/db/stub-auth.sql
  • supabase/migrations/20260804193000_board_invites.sql
  • test/browser/auth.test.js
  • supabase/migrations/20260804171500_board_version.sql
  • test/db/apply.js
  • test/node/store.test.js
  • test/browser/app.test.js
  • test/coverage.js
  • test/node/supabase-repository.test.js
  • test/browser/boards.test.js

Comment thread README.md Outdated
Comment thread src/platform/supabase-repository.js
Comment thread src/platform/sync.js
Comment thread src/routes/BoardListPage.jsx
Comment thread test/helpers/supabase.js Outdated
Comment thread test/node/sharing.test.js Outdated
Comment thread test/node/sharing.test.js Outdated
Comment thread test/node/sync.test.js
@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 17 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 13

♻️ Duplicate comments (14)
src/platform/auth.js (1)

77-83: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Release the fulfilled startup memo before a later guest retry.

After a successful start(), starting remains a fulfilled { ok: true } promise. If onAuthStateChange later publishes null for an external sign-out, the retry effect in RequireAccount receives cached success. It does not call signInAnonymously, so the user remains without an account.

Cache only the in-flight attempt, or clear the memo after it settles. Add coverage that emits null after a successful start and then calls start() again. This path is separate from the previously reported rejected-start path.

Proposed fix
 start() {
-  starting ??= startOnce().then((result) => {
-    if (!result.ok) starting = null;
-    return result;
-  });
+  if (!starting) {
+    const attempt = startOnce().catch((error) =>
+      failed(error, 'Could not start a session.'),
+    );
+    starting = attempt;
+    void attempt.finally(() => {
+      if (starting === attempt) starting = null;
+    });
+  }
   return starting;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/auth.js` around lines 77 - 83, Update the startup memoization in
start() so starting retains only the in-flight attempt and is cleared after both
successful and failed settlement, allowing a later start() after an external
null auth state to invoke startOnce() again. Add coverage for a successful start
followed by a null auth-state emission and a second start() call, verifying the
retry performs anonymous sign-in rather than returning cached success.
test/db/apply.js (1)

59-59: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

import.meta.main is undefined before Node 22.2.

The project declares "engines": { "node": ">=22" }, which admits Node 22.0 and 22.1. import.meta.main landed in 22.2.0. On 22.0 and 22.1 this condition is always false, and node test/db/apply.js --stub exits without applying any migration and without reporting anything.

fileURLToPath is already imported at line 12, so the version-agnostic check costs nothing.

🐛 Proposed fix
-if (import.meta.main) {
+if (process.argv[1] === fileURLToPath(import.meta.url)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db/apply.js` at line 59, Replace the unsupported import.meta.main
condition in test/db/apply.js with a Node 22.0-compatible entry-point check
using the existing fileURLToPath import, so direct execution still invokes the
migration logic while preserving module-import behavior.
test/node/sync.test.js (3)

186-188: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

deliveries <= 2 is a wire claim, not a throttle claim.

The three ops share one throttle window on a manual scheduler, so the local split is deterministic. Delivery is not: the leading message and the trailed message are two hops, and Realtime can split or coalesce them. The ceiling of 2 therefore encodes an assumption about the network.

Assert the property you own. Count the outbound messages the throttle produced, or assert deliveries < 3 and state the reason as fewer messages than ops.

As per path instructions: "flag ... probabilistic claims without margin".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` around lines 186 - 188, Replace the `deliveries <= 2`
assertion with a deterministic assertion about the throttle’s outbound message
count, or use `deliveries < 3` only if that is the owned behavior; update the
assertion message to explain that fewer messages than operations are expected,
without assuming Realtime’s exact delivery count.

Source: Path instructions


81-88: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Make waitFor throw when the deadline passes.

waitFor returns false on timeout. Lines 138, 287, 335, 355, and 357 discard the return value. At line 138 a timeout means Bob never received the op, and the assertions at lines 145-146 then pass against an empty store, so the test reports success while delivery is broken.

Throw inside the helper. The assert.ok(await waitFor(...)) wrappers stay valid, and their messages become unreachable rather than wrong.

As per path instructions: "flag assertions that could be flaky".

🔧 Proposed fix
   const waitFor = async (predicate, label, timeout = 5000) => {
     const deadline = Date.now() + timeout;
     while (Date.now() < deadline) {
       if (predicate()) return true;
       await new Promise((r) => setTimeout(r, 20));
     }
-    return false;
+    throw new Error(`timed out after ${timeout}ms waiting for ${label}`);
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` around lines 81 - 88, Update the waitFor helper to
throw an error when its timeout deadline is reached instead of returning false;
retain the existing successful true return and polling behavior so current
assert.ok(await waitFor(...)) calls remain valid.

Source: Path instructions


90-90: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

settle() is a fixed sleep behind every negative assertion.

settle() waits 250 ms, and lines 143, 203, 224, and 245 then claim a message did not arrive. On a loaded runner the message is still in flight after 250 ms, so the assertion proves nothing and the suite stays green while echo suppression, destroy(), or the channel policy is broken.

Anchor each negative claim to a positive readiness condition. Send a marker op after the op under test, wait for the marker to arrive, then assert the forbidden op is absent.

As per path instructions: "flag assertions that could be flaky — fixed sleeps standing in for a real readiness condition".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sync.test.js` at line 90, Replace the fixed-sleep pattern used with
settle() at the negative assertion checks by implementing a marker-based
readiness condition. For each negative assertion (the checks claiming a message
did not arrive), send a marker operation after the operation under test, wait
for the marker to arrive, then assert that the forbidden operation is absent.
This anchors each negative claim to a positive readiness signal, ensuring the
assertion is reliable rather than depending on whether 250ms is sufficient on a
loaded test runner.

Source: Path instructions

src/platform/sync.js (3)

113-125: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Register the store listener after the channel exists.

store.onOps(...) at line 113 runs before client.channel(...) at line 123. If client.channel() throws, createBoardSync never returns, so nothing can call destroy() and unsubscribeStore is unreachable. The listener stays on the store, and the next local op enters flush, which dereferences the uninitialised channel binding inside a scheduler callback.

Move the store.onOps registration below the client.channel call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/sync.js` around lines 113 - 125, Move the store.onOps
registration and its unsubscribeStore assignment below the client.channel call
in createBoardSync, ensuring the channel is successfully created before the
listener can enqueue operations or invoke flush. Preserve the existing stopped
and LOCAL checks and callback behavior.

103-111: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not drop op batches before delivery is confirmed.

queue.splice removes the ops before channel.send resolves, and broadcast.ack is not enabled, so a failed send discards the batch silently. Retain the batch and re-queue it when the send reports "error" or "timed out".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/sync.js` around lines 103 - 111, Update the flush callback and
channel.send handling so queued ops are not permanently removed before delivery
succeeds. Retain the batch from queue, and re-queue it when the send result
reports “error” or “timed out”; preserve successful delivery and stopped-session
behavior while avoiding reliance on broadcast.ack.

127-134: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Validate each remote op before you apply the batch.

Line 129 checks only that payload.ops is an array. store.apply then reads op.obj.id, Object.keys(op.patch), and op.order.filter without guards. A malformed element throws mid-loop, after earlier ops in the batch have already mutated objects and order and before emit runs, so the store and the DOM diverge.

The cursor handler at lines 136-142 already validates field by field. Apply the same standard here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/sync.js` around lines 127 - 134, Validate every element of
message.payload.ops before calling store.apply in the broadcast handler: require
the same per-field structure used by the cursor handler, including a valid
op.obj.id, patch object, and order array. If any remote op is malformed, reject
the entire batch before mutation; otherwise preserve the existing
store.apply(ops, false, REMOTE) behavior.
src/platform/supabase-repository.js (2)

116-146: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard returned rows before you read length or index them.

Line 121 reads data.length and line 144 reads inserted.data[0].version. A successful PostgREST response can carry null or an empty array when the read-back after a write is not permitted by the select policy. Either line then throws a TypeError, and save() must never throw.

The same shape appears at line 71 (data.map) and line 165 (data.length), so fix all four together.

🛡️ Proposed fix
-      if (error) return [];
+      if (error || !data) return [];
@@
-        if (error || !data.length) return { error, wrote: false };
+        if (error || !data?.length) return { error, wrote: false };
         versions.set(id, data[0].version);
@@
-      if (!inserted.error) {
+      if (!inserted.error && inserted.data?.length) {
         versions.set(id, inserted.data[0].version);
         return true;
       }
@@
-      return !error && data.length > 0;
+      return !error && (data?.length ?? 0) > 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/supabase-repository.js` around lines 116 - 146, Update save()
and the related read paths so nullable Supabase data is validated before use:
guard data before data.map, require a non-empty array before checking
data.length, and require inserted.data to contain a row before reading
inserted.data[0].version. Ensure these guards return the existing failure result
rather than throwing, preserving save()’s no-throw behavior across all four
locations.

80-92: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clear versions when the row is confirmed absent or deleted.

load() keeps a stale version after the board disappears, and remove() never clears one. A later save() then sees expected !== undefined, finds nothing to update, and returns false at line 137 instead of recreating the board the caller seeded.

Also applies to: 174-177

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/supabase-repository.js` around lines 80 - 92, Update the
repository’s version-tracking lifecycle: clear the entry for id from versions in
load() whenever the row is absent, invalid/deleted, or otherwise confirmed
missing, and also clear it in remove() after deletion. Preserve version
assignment for successfully loaded boards so a later save can recreate a missing
board instead of using a stale expected version.
test/helpers/supabase.js (1)

28-33: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

execFileSync('npx', ...) still fails on Windows.

On Windows, npx is npx.cmd. execFileSync cannot run a .cmd shim without shell: true; the call throws ENOENT or EINVAL. The catch on line 38 turns that into null, so every Supabase-backed suite skips silently even when a local stack is running.

🐛 Proposed fix
     const out = execFileSync('npx', ['supabase', 'status', '-o', 'json'], {
       cwd: ROOT,
       encoding: 'utf8',
       stdio: ['ignore', 'pipe', 'ignore'],
       timeout: 60_000,
+      shell: process.platform === 'win32',
     });
Node.js execFileSync npx.cmd Windows shell option required
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/helpers/supabase.js` around lines 28 - 33, The execFileSync call with
npx fails on Windows because it cannot execute the npx.cmd shim without shell
support. Add shell: true to the options object passed to execFileSync alongside
the existing cwd, encoding, stdio, and timeout properties to enable the command
to run properly on Windows systems while preserving the existing behavior on
other platforms.
test/node/sharing.test.js (2)

157-164: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The unauthorized-removal test cannot fail.

Alice is the owner and has no row in board_members. Bob's delete therefore affects zero rows even if the delete policy wrongly allowed him to remove any member. Add a third member and let Bob target that member.

💚 Proposed test change
       const id = await aliceBoard();
       const { token } = await alice.sharing.share(id, 'editor');
       await bob.sharing.redeem(token);
+      const carol = await signIn();
+      await carol.sharing.redeem(token);
 
-      await bob.sharing.remove(id, alice.id);
-      assert.equal((await alice.sharing.people(id)).length, 2, 'an editor removed the owner');
+      assert.equal(await bob.sharing.remove(id, carol.id), false);
+      assert.equal(
+        (await alice.sharing.people(id)).some((p) => p.id === carol.id),
+        true,
+        'an editor removed another member',
+      );

The false assertion depends on the remove fix in src/platform/sharing.js.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sharing.test.js` around lines 157 - 164, The test cannot fail
because Alice as owner has no row in board_members, so Bob's remove operation
affects zero rows regardless of authorization. Add a third member to the board
(neither Alice nor Bob), then have Bob attempt to remove that third member
instead of Alice. Verify the final member count still shows that Bob's
unauthorized removal did not succeed.

4-4: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Two test files import @supabase/supabase-js directly. The path instructions for test/** permit only the Vite import. Both files build a client themselves instead of going through the app's client boundary, createSupabaseClient in src/platform/supabase.js.

  • test/node/sharing.test.js#L4: replace the createClient import with createSupabaseClient from src/platform/supabase.js and build the client through it in signIn.
  • test/node/supabase-repository.test.js#L4: apply the same replacement in its own signIn.

As per path instructions: "Vite is the one permitted npm import and only inside test/helpers/vite.js … Flag any other added npm dependency."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/sharing.test.js` at line 4, In test/node/sharing.test.js at line 4,
replace the import of createClient from `@supabase/supabase-js` with an import of
createSupabaseClient from src/platform/supabase.js, then update the signIn
function to build the client through createSupabaseClient instead of
createClient directly. Apply the identical replacement in
test/node/supabase-repository.test.js at line 4, updating its signIn function to
use createSupabaseClient in the same way. This ensures both test files respect
the path instructions by routing Supabase client creation through the app's
client boundary rather than importing directly from an npm dependency.

Source: Path instructions

src/platform/sharing.js (1)

51-54: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

revoke still reports success when nothing was deleted.

revoke returns !error. Under row level security a refused delete affects zero rows and raises no error. test/db/rls.test.js line 309 shows exactly this: an editor's delete on board_invites completes without an error and removes nothing. remove on lines 69-77 has the same problem. leave on line 90 already returns the row count, so use that pattern in both.

🐛 Proposed fix
     async revoke(boardId) {
-      const { error } = await invites().delete().eq('board_id', boardId);
-      return !error;
+      const { data, error } = await invites().delete().eq('board_id', boardId).select('board_id');
+      return !error && (data?.length ?? 0) > 0;
     },
     async remove(boardId, userId) {
-      const { error } = await client
+      const { data, error } = await client
         .from('board_members')
         .delete()
         .eq('board_id', boardId)
-        .eq('user_id', userId);
+        .eq('user_id', userId)
+        .select('board_id');
 
-      return !error;
+      return !error && (data?.length ?? 0) > 0;
     },

Line 101 reads data.length directly. Use data?.length ?? 0 there too.

Also applies to: 69-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/sharing.js` around lines 51 - 54, The revoke method (lines
51-54) and remove method (lines 69-77) both return success based only on the
absence of an error, but under row level security a refused delete raises no
error and affects zero rows. Update both methods to verify that rows were
actually deleted by checking the returned data count (use the pattern from the
leave method which already returns the row count), ensuring the methods return
true only when the delete operation actually removed records. Additionally,
update line 101 to safely access data length using the optional chaining pattern
data?.length ?? 0 to handle cases where data may be undefined.
🧹 Nitpick comments (6)
test/node/store.test.js (1)

252-261: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a test for the inverse of a merged order op.

The order case in apply records { t: 'order', order: this.order.slice() } as its inverse and then rewrites this.order through mergeOrder. The inverse is replayed through mergeOrder too, so undo restores the exact prior order only because the inverse array names every known id. No test in this suite covers that path.

Add a recorded order op that the merge actually changes, then assert that undo() reproduces the original order string.

💚 Suggested test
+    test('undoing a merged order op restores the order it replaced', () => {
+      store.apply([{ t: 'add', obj: card('a') }, { t: 'add', obj: card('b') }]);
+      store.apply([{ t: 'add', obj: card('mine') }]);
+      const before = order();
+
+      // recorded, and naming less than the document holds
+      store.apply([{ t: 'order', order: ['b', 'a'] }]);
+      assert.notEqual(order(), before);
+
+      assert.equal(store.undo(), true);
+      assert.equal(order(), before);
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/store.test.js` around lines 252 - 261, Add a test in the
order-operation section that applies an order operation whose result is modified
by merging with the existing order, records the original order string, calls
undo(), and asserts the order is restored exactly. Reuse the existing store
setup, order helper, and REMOTE constants while ensuring the test exercises the
recorded inverse through mergeOrder.
test/browser/shell.test.js (1)

110-125: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Make loading-state observation deterministic.

The local list() method resolves without awaiting an asynchronous operation. The test can therefore capture only the settled DOM, and its assertions allow that result. Add a delayed-read fixture, then assert that [data-loading] was observed before [data-empty].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/shell.test.js` around lines 110 - 125, Update the browser test
around the local list() fixture so its read operation is delayed and remains
pending long enough to observe the loading state. Capture/assert that
[data-loading] is present before releasing or completing the delayed read, then
assert that [data-empty] appears afterward; replace the current race-tolerant
assertions in “says nothing about being empty until the boards have been read”
with this deterministic sequence.
test/db/rls.test.js (1)

244-256: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clean up carol even when an assertion fails.

Line 255 deletes carol only after the two assertions. If either assertion fails, the row stays in auth.users for the rest of the run and for later runs. Delete the row in a finally block, or register the id with the after hook that already cleans up alice and bob.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db/rls.test.js` around lines 244 - 256, Update the test named “an owner
sees every member; a member sees only themselves” so the temporary carol auth
user is always deleted, even when an assertion fails. Move the cleanup into a
finally block or register carol with the existing after-hook cleanup for alice
and bob, while preserving the current assertions.
test/browser/auth.test.js (1)

32-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the test address collision-proof across hosts.

uniqueEmail derives uniqueness from process.pid and a counter. That is unique within one run on one machine. If SUPABASE_URL points at a shared stack, two machines can produce the same pid and the same counter, and the registration then fails against a row that already exists. Add a random component.

♻️ Proposed change
+import { randomUUID } from 'node:crypto';
+
+  const run = randomUUID().slice(0, 8);
-  const uniqueEmail = () => `peerthink-${process.pid}-${counter++}`@example.com``;
+  const uniqueEmail = () => `peerthink-${run}-${counter++}`@example.com``;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/browser/auth.test.js` around lines 32 - 35, Update the uniqueEmail
generator to include a random component in addition to process.pid and counter,
ensuring registration addresses remain collision-proof across hosts sharing the
same SUPABASE_URL.
test/node/supabase-repository.test.js (1)

233-245: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the first doc comment to the test it describes.

Two doc comments sit back to back above describe('write authority'). The first one describes two tabs racing one new board, which is the test at line 339, not this describe block. Move it there so each comment sits above the code it explains.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/supabase-repository.test.js` around lines 233 - 245, Move the first
JSDoc comment describing two tabs racing to insert a new board from above
describe('write authority') to the specific test around the concurrent new-board
updates at line 339, leaving the write-authority comment above its describe
block.
supabase/migrations/20260804101204_boards.sql (1)

94-102: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Trigger functions are created without a fixed search_path. board_role() pins search_path = public, pg_temp, but the three trigger functions added by these migrations do not. All three run with invoker rights, so the exposure is small. Pinning removes the dependency on the caller's search_path and makes the migrations consistent.

  • supabase/migrations/20260804101204_boards.sql#L94-L102: add set search_path = pg_catalog, public, pg_temp to touch_updated_at(), and the same to freeze_board_owner() at lines 116-126.
  • supabase/migrations/20260804171500_board_version.sql#L22-L37: add the same clause to bump_board_version().
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260804101204_boards.sql` around lines 94 - 102, The
trigger functions touch_updated_at(), freeze_board_owner(), and
bump_board_version() lack a fixed search_path setting, creating inconsistency
with board_role() which pins search_path to public, pg_temp. Add the clause `set
search_path = pg_catalog, public, pg_temp` to each trigger function definition
to ensure they always execute with a consistent search path regardless of the
caller's settings. In supabase/migrations/20260804101204_boards.sql, add this
clause to the touch_updated_at() function (lines 94-102) and to the
freeze_board_owner() function (lines 116-126). In
supabase/migrations/20260804171500_board_version.sql, add the same clause to the
bump_board_version() function (lines 22-37).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Around line 28-29: Remove the newly added pg and supabase dependencies from
package.json and replace the integration tests with the permitted test/**
toolchain: node:test, raw CDP, and Node’s built-in WebSocket, using Vite only
where allowed. If these dependencies are required, obtain and record an explicit
approved exception instead of leaving them unapproved.

In `@src/app.js`:
- Around line 145-167: Move the createAutosave initialization earlier in the
restore flow: keep it after store.load(saved.board) when saved data exists, but
before store.apply(early, false) or seed(board). Preserve the existing autosave
options and metadata assignments while ensuring replayed hydration edits and
seeded-board changes are observed and scheduled for saving.

In `@src/components/BoardCanvas.jsx`:
- Around line 48-50: Update the app.hydrate() failure handling in BoardCanvas so
a rejected migrateLegacy() or load() does not leave the canvas editable without
persistence. Show a visible error with a retry action, or establish a safe
persistence fallback before enabling edits; do not silently swallow the
rejection with an empty catch.

In `@src/core/autosave.js`:
- Around line 20-22: Update createAutosave’s flush and debounced save flow so a
rejected repository.save or a false result does not clear the board’s dirty
state; retain the unsaved snapshot and retry it when scheduling or
reconciliation occurs, including handling version-refused saves. Only mark the
board clean after repository.save resolves successfully, while preserving the
existing canWrite check and debounce behavior.

In `@src/platform/auth.js`:
- Around line 85-127: Handle promise rejections in signIn, register, and
signOut: wrap signInWithPassword, signUp, and updateUser calls in try-catch and
return failed(error, ...) using the existing operation-specific messages, while
preserving resolved error handling and success results. In signOut, move
publish(null) and starting = null into a finally block so local state resets
even when client.auth.signOut rejects. Add rejection coverage for all four auth
operations.

In `@src/platform/supabase.js`:
- Around line 35-38: The authentication flow is incomplete because disabling
detectSessionInUrl leaves confirmation codes unprocessed. Add a callback route
in the router that reads the URL’s code parameter and invokes
client.auth.exchangeCodeForSession(code), integrating the resulting
session/error handling with the existing auth.js flow; alternatively, restore
automatic URL detection until that callback exists.
- Line 1: Remove the createClient import from src/platform/supabase.js and move
it to src/shell/auth.js. Update the createSupabaseClient function (or whichever
factory function uses createClient) to accept createClient as an injected
parameter instead of importing it directly. Update the call site in
src/shell/auth.js to pass the imported createClient when invoking the factory
function.
- Around line 17-20: Update readSupabaseConfig to parse VITE_SUPABASE_URL and
accept only HTTPS endpoints, while allowing an explicit loopback-only exception
for local development over HTTP. Return null for malformed URLs or non-HTTPS,
non-loopback endpoints, and preserve the existing trimmed URL/anonKey result for
valid configurations.

In `@supabase/migrations/20260804193000_board_invites.sql`:
- Around line 125-136: The board_people query orders roles lexically, allowing
viewers to precede the owner. Wrap the UNION in a subquery with an explicit rank
column, order by rank first, then role and email, while keeping the declared
return columns limited to user_id, email, and role; add coverage for a viewer
member.

In `@test/browser/app.test.js`:
- Around line 260-269: Replace the fixed 30 ms timeout after await hydrated in
the test with an injected manual scheduler tied to the autosave timing
mechanism. Flush the scheduler’s pending timers after hydration resolves, then
assert that writes remains empty, preserving the existing result checks and
destroy-during-load scenario.

In `@test/db/apply.js`:
- Line 14: Clarify the `pg` dependency and execution model in the
`test/db/apply.js` utility: verify whether `pg` is already present in
`package.json`, and if it is new, document why raw SQL via `pg.Client` is
required instead of `@supabase/supabase-js` or the Supabase CLI, including the
Node-context limitation if applicable. Because this utility resides under
`test/**` but is not a test, either move it outside that constrained path or add
an explicit documented exception explaining the direct npm import.

In `@test/db/rls.test.js`:
- Line 12: The import of pg at the top of the rls.test.js file violates the path
instruction that only Vite is permitted as an npm import within test files.
Either remove the pg import and replace direct database calls with equivalent
operations through the existing Supabase client using PostgREST, or document a
clear justification in the PR explaining why pg is necessary and add it to the
exception list in the path instructions.

In `@test/helpers/cdp.js`:
- Around line 90-113: Update newIsolatedPage so any rejection during
Target.createBrowserContext, Target.createTarget, or the page CDPSession.connect
closes the already-open browser session and disposes the browser context when it
has been created. Wrap setup in failure cleanup while preserving the returned
dispose behavior for successful initialization.

---

Duplicate comments:
In `@src/platform/auth.js`:
- Around line 77-83: Update the startup memoization in start() so starting
retains only the in-flight attempt and is cleared after both successful and
failed settlement, allowing a later start() after an external null auth state to
invoke startOnce() again. Add coverage for a successful start followed by a null
auth-state emission and a second start() call, verifying the retry performs
anonymous sign-in rather than returning cached success.

In `@src/platform/sharing.js`:
- Around line 51-54: The revoke method (lines 51-54) and remove method (lines
69-77) both return success based only on the absence of an error, but under row
level security a refused delete raises no error and affects zero rows. Update
both methods to verify that rows were actually deleted by checking the returned
data count (use the pattern from the leave method which already returns the row
count), ensuring the methods return true only when the delete operation actually
removed records. Additionally, update line 101 to safely access data length
using the optional chaining pattern data?.length ?? 0 to handle cases where data
may be undefined.

In `@src/platform/supabase-repository.js`:
- Around line 116-146: Update save() and the related read paths so nullable
Supabase data is validated before use: guard data before data.map, require a
non-empty array before checking data.length, and require inserted.data to
contain a row before reading inserted.data[0].version. Ensure these guards
return the existing failure result rather than throwing, preserving save()’s
no-throw behavior across all four locations.
- Around line 80-92: Update the repository’s version-tracking lifecycle: clear
the entry for id from versions in load() whenever the row is absent,
invalid/deleted, or otherwise confirmed missing, and also clear it in remove()
after deletion. Preserve version assignment for successfully loaded boards so a
later save can recreate a missing board instead of using a stale expected
version.

In `@src/platform/sync.js`:
- Around line 113-125: Move the store.onOps registration and its
unsubscribeStore assignment below the client.channel call in createBoardSync,
ensuring the channel is successfully created before the listener can enqueue
operations or invoke flush. Preserve the existing stopped and LOCAL checks and
callback behavior.
- Around line 103-111: Update the flush callback and channel.send handling so
queued ops are not permanently removed before delivery succeeds. Retain the
batch from queue, and re-queue it when the send result reports “error” or “timed
out”; preserve successful delivery and stopped-session behavior while avoiding
reliance on broadcast.ack.
- Around line 127-134: Validate every element of message.payload.ops before
calling store.apply in the broadcast handler: require the same per-field
structure used by the cursor handler, including a valid op.obj.id, patch object,
and order array. If any remote op is malformed, reject the entire batch before
mutation; otherwise preserve the existing store.apply(ops, false, REMOTE)
behavior.

In `@test/db/apply.js`:
- Line 59: Replace the unsupported import.meta.main condition in
test/db/apply.js with a Node 22.0-compatible entry-point check using the
existing fileURLToPath import, so direct execution still invokes the migration
logic while preserving module-import behavior.

In `@test/helpers/supabase.js`:
- Around line 28-33: The execFileSync call with npx fails on Windows because it
cannot execute the npx.cmd shim without shell support. Add shell: true to the
options object passed to execFileSync alongside the existing cwd, encoding,
stdio, and timeout properties to enable the command to run properly on Windows
systems while preserving the existing behavior on other platforms.

In `@test/node/sharing.test.js`:
- Around line 157-164: The test cannot fail because Alice as owner has no row in
board_members, so Bob's remove operation affects zero rows regardless of
authorization. Add a third member to the board (neither Alice nor Bob), then
have Bob attempt to remove that third member instead of Alice. Verify the final
member count still shows that Bob's unauthorized removal did not succeed.
- Line 4: In test/node/sharing.test.js at line 4, replace the import of
createClient from `@supabase/supabase-js` with an import of createSupabaseClient
from src/platform/supabase.js, then update the signIn function to build the
client through createSupabaseClient instead of createClient directly. Apply the
identical replacement in test/node/supabase-repository.test.js at line 4,
updating its signIn function to use createSupabaseClient in the same way. This
ensures both test files respect the path instructions by routing Supabase client
creation through the app's client boundary rather than importing directly from
an npm dependency.

In `@test/node/sync.test.js`:
- Around line 186-188: Replace the `deliveries <= 2` assertion with a
deterministic assertion about the throttle’s outbound message count, or use
`deliveries < 3` only if that is the owned behavior; update the assertion
message to explain that fewer messages than operations are expected, without
assuming Realtime’s exact delivery count.
- Around line 81-88: Update the waitFor helper to throw an error when its
timeout deadline is reached instead of returning false; retain the existing
successful true return and polling behavior so current assert.ok(await
waitFor(...)) calls remain valid.
- Line 90: Replace the fixed-sleep pattern used with settle() at the negative
assertion checks by implementing a marker-based readiness condition. For each
negative assertion (the checks claiming a message did not arrive), send a marker
operation after the operation under test, wait for the marker to arrive, then
assert that the forbidden operation is absent. This anchors each negative claim
to a positive readiness signal, ensuring the assertion is reliable rather than
depending on whether 250ms is sufficient on a loaded test runner.

---

Nitpick comments:
In `@supabase/migrations/20260804101204_boards.sql`:
- Around line 94-102: The trigger functions touch_updated_at(),
freeze_board_owner(), and bump_board_version() lack a fixed search_path setting,
creating inconsistency with board_role() which pins search_path to public,
pg_temp. Add the clause `set search_path = pg_catalog, public, pg_temp` to each
trigger function definition to ensure they always execute with a consistent
search path regardless of the caller's settings. In
supabase/migrations/20260804101204_boards.sql, add this clause to the
touch_updated_at() function (lines 94-102) and to the freeze_board_owner()
function (lines 116-126). In
supabase/migrations/20260804171500_board_version.sql, add the same clause to the
bump_board_version() function (lines 22-37).

In `@test/browser/auth.test.js`:
- Around line 32-35: Update the uniqueEmail generator to include a random
component in addition to process.pid and counter, ensuring registration
addresses remain collision-proof across hosts sharing the same SUPABASE_URL.

In `@test/browser/shell.test.js`:
- Around line 110-125: Update the browser test around the local list() fixture
so its read operation is delayed and remains pending long enough to observe the
loading state. Capture/assert that [data-loading] is present before releasing or
completing the delayed read, then assert that [data-empty] appears afterward;
replace the current race-tolerant assertions in “says nothing about being empty
until the boards have been read” with this deterministic sequence.

In `@test/db/rls.test.js`:
- Around line 244-256: Update the test named “an owner sees every member; a
member sees only themselves” so the temporary carol auth user is always deleted,
even when an assertion fails. Move the cleanup into a finally block or register
carol with the existing after-hook cleanup for alice and bob, while preserving
the current assertions.

In `@test/node/store.test.js`:
- Around line 252-261: Add a test in the order-operation section that applies an
order operation whose result is modified by merging with the existing order,
records the original order string, calls undo(), and asserts the order is
restored exactly. Reuse the existing store setup, order helper, and REMOTE
constants while ensuring the test exercises the recorded inverse through
mergeOrder.

In `@test/node/supabase-repository.test.js`:
- Around line 233-245: Move the first JSDoc comment describing two tabs racing
to insert a new board from above describe('write authority') to the specific
test around the concurrent new-board updates at line 339, leaving the
write-authority comment above its describe block.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d903dbda-6ced-4376-9474-2cf9236ffbe5

📥 Commits

Reviewing files that changed from the base of the PR and between 301a38e and fc8de56.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (58)
  • .github/workflows/ci.yml
  • .gitignore
  • README.md
  • package.json
  • src/App.jsx
  • src/app.js
  • src/components/AccountForm.jsx
  • src/components/AccountMenu.jsx
  • src/components/BoardCanvas.jsx
  • src/components/ShareDialog.jsx
  • src/core/autosave.js
  • src/core/scheduler.js
  • src/core/store.js
  • src/platform/auth.js
  • src/platform/cursors.js
  • src/platform/sharing.js
  • src/platform/storage.js
  • src/platform/supabase-repository.js
  • src/platform/supabase.js
  • src/platform/sync.js
  • src/routes/BoardListPage.jsx
  • src/routes/BoardPage.jsx
  • src/routes/JoinPage.jsx
  • src/shell/RequireAccount.jsx
  • src/shell/auth.js
  • src/shell/sharing.js
  • src/shell/storage.js
  • src/shell/sync.js
  • src/shell/web-storage.js
  • src/styles/canvas.css
  • src/styles/shell.css
  • supabase/.gitignore
  • supabase/config.toml
  • supabase/migrations/20260804101204_boards.sql
  • supabase/migrations/20260804154000_board_broadcast.sql
  • supabase/migrations/20260804171500_board_version.sql
  • supabase/migrations/20260804193000_board_invites.sql
  • test/browser/app.test.js
  • test/browser/auth.test.js
  • test/browser/boards.test.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/db/apply.js
  • test/db/rls.test.js
  • test/db/stub-auth.sql
  • test/helpers/browser.js
  • test/helpers/cdp.js
  • test/helpers/supabase.js
  • test/helpers/vite.js
  • test/node/auth.test.js
  • test/node/autosave.test.js
  • test/node/scheduler.test.js
  • test/node/sharing.test.js
  • test/node/storage.test.js
  • test/node/store.test.js
  • test/node/supabase-repository.test.js
  • test/node/sync.test.js
  • test/run.js

Comment thread package.json
Comment thread src/app.js
Comment thread src/components/BoardCanvas.jsx
Comment thread src/core/autosave.js Outdated
Comment thread src/platform/auth.js
Comment thread supabase/migrations/20260804193000_board_invites.sql Outdated
Comment thread test/browser/app.test.js
Comment thread test/db/apply.js
Comment thread test/db/rls.test.js
Comment thread test/helpers/cdp.js
Ten findings from review, and a trap found while checking them.

`start()` promised an answer and one path did not keep the promise. A client
that *throws* — a transport that never came back — left the account gate on
"Loading…" with no retry, because the caller is a .then() with nowhere to put a
rejection.

`revoke` and `remove` reported success for a delete that changed nothing. A
delete the policies refuse matches no rows and reports no error, so a member
revoking their owner's link came back true and the dialog cleared a link that
was still live. Both now ask which rows went, the way leave() already did.

A remote batch is validated before it is applied. `apply` trusts what it is
handed, which is right in-process and wrong over a wire — an `add` with no
`obj` threw on the way in and took the receiving session with it. The check
lives in store.js, where the vocabulary is defined, rather than in the
transport. The whole batch is dropped rather than the bad op filtered out: a
sender emitting one op this client cannot read is a sender whose other ops mean
something it does not understand either.

The stub's auth.uid() is created only if absent. `--stub` is documented for a
bare Postgres, but nothing stopped it being pointed at a real project, where
`create or replace` would swap the function every RLS policy in the database
reads. Verified against a real GoTrue: the definition is untouched.

waitFor answered false on timeout, which made every bare `await waitFor(...)` a
no-op — the thing never arrived, the test carried on, and whatever it asserted
next passed for the wrong reason. It throws now. The fixed 250ms sleep behind
the negative assertions is gone too: a message that *is* expected is sent after
the one that is not, and the assertion is made once that has landed, so the
thing being denied has demonstrably had its turn on the wire.

Also: import.meta.main is undefined before Node 22.19 and package.json allows
22.0; both entry-point checks compare URLs instead. Guards on an insert that
returns no row, and on the two forms, whose only way out of `busy` was the
happy path.

And the trap. Vite reads .env.local, so a developer who has run the app against
a local stack was serving a Supabase build to the origin the offline suites
test — 26 failures with nothing in the diff to explain them. test/run.js blanks
the two variables for that server, so it is offline because this file says so
rather than because of what is on disk, and a test now notices if it stops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/store.js (1)

131-139: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the overwritten object in the inverse.

If op.obj.id already exists, Lines 131-138 replace the object but retain its order entry. Line 139 records a del inverse. Undo then deletes the previous object instead of restoring it.

When replacing an existing ID, record a cloned add inverse with the prior object and its current index. Use del only for a newly inserted ID.

Proposed fix
         case 'add': {
           const obj = structuredClone(op.obj);
+          const previous = this.objects.get(obj.id);
+          const existingIndex = this.order.indexOf(obj.id);
           const index = op.index ?? this.order.length;
           this.objects.set(obj.id, obj);
-          if (!this.order.includes(obj.id)) this.order.splice(index, 0, obj.id);
-          inverse.unshift({ t: 'del', id: obj.id });
+          if (existingIndex === -1) {
+            this.order.splice(index, 0, obj.id);
+            inverse.unshift({ t: 'del', id: obj.id });
+          } else {
+            inverse.unshift({
+              t: 'add',
+              obj: structuredClone(previous),
+              index: existingIndex,
+            });
+          }

As per path instructions: "Store mutations must go through apply(ops) with a correct inverse."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/store.js` around lines 131 - 139, Update the mutation handling
around the object replacement logic in apply(ops) so an existing obj.id records
an inverse add containing the previously stored object clone and its current
order index; retain the existing order entry. Record the del inverse only when
the ID is newly inserted, preserving correct undo behavior for both cases.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/store.js`:
- Around line 33-34: Update the `isOp` validation for the `'set'` case to reject
patches with an own `id` property while preserving validation of string IDs and
object patches. Add a regression test covering a remote batch with an
ID-changing set patch, verifying the batch is rejected and store object IDs,
`objects`, and `order` remain consistent.

---

Outside diff comments:
In `@src/core/store.js`:
- Around line 131-139: Update the mutation handling around the object
replacement logic in apply(ops) so an existing obj.id records an inverse add
containing the previously stored object clone and its current order index;
retain the existing order entry. Record the del inverse only when the ID is
newly inserted, preserving correct undo behavior for both cases.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dfc368cf-0011-4d2c-9e71-573ba9359876

📥 Commits

Reviewing files that changed from the base of the PR and between fc8de56 and 70469e6.

📒 Files selected for processing (16)
  • src/components/AccountForm.jsx
  • src/components/ShareDialog.jsx
  • src/core/store.js
  • src/platform/auth.js
  • src/platform/sharing.js
  • src/platform/supabase-repository.js
  • src/platform/sync.js
  • test/browser/shell.test.js
  • test/coverage.js
  • test/db/apply.js
  • test/db/stub-auth.sql
  • test/node/auth.test.js
  • test/node/sharing.test.js
  • test/node/store.test.js
  • test/node/sync.test.js
  • test/run.js
🚧 Files skipped from review as they are similar to previous changes (10)
  • test/run.js
  • src/platform/supabase-repository.js
  • src/platform/auth.js
  • test/node/sharing.test.js
  • test/db/apply.js
  • src/components/AccountForm.jsx
  • src/platform/sharing.js
  • test/node/store.test.js
  • test/node/auth.test.js
  • src/platform/sync.js

Comment thread src/core/store.js Outdated
Janacek and others added 2 commits August 4, 2026 16:29
The review arrived in three waves and I had triaged only the first. These are
the substantive findings from the other two.

board_people ordered by the role *text*, descending — which puts 'viewer'
above 'owner'. Any board with a viewer listed them before its owner. Both
existing tests used an editor, where 'owner' happens to sort higher, so they
passed on a coincidence; the new one uses a viewer, which is what exposes it.
The union is wrapped so the ordering has named columns to sort on, because
across a UNION `order by` cannot see an expression over the output list at all.

The replay added in the previous commit never scheduled a save. Autosave
subscribes after the load, deliberately, so a restore does not write itself
straight back — which meant the ops replayed onto the snapshot happened with
nobody listening, and a card made while the board was loading persisted only if
the user happened to edit again. It flushes explicitly now.

A failed save left no trace. The debounced path discarded both rejections and a
`false` result, so a write refused because someone else's version is current —
or dropped because the network was — was never retried, and the session lost
edits it was still holding. The board stays dirty until a write lands, and the
retry rides the next settled edit rather than a timer, so a board nobody is
touching does not hammer a server that just said no.

The rest of the auth port got the rejection handling start() got last time:
signIn, register and signOut all promised an answer and could reject instead.

And two tests that could not fail. The unauthorized-removal test aimed Bob at
Alice, who is the *owner* and has no row in board_members — so his delete
matched nothing whatever the policy said, and it passed with the policy dropped
entirely. It goes through Carol now. The isolated-page helper leaked its
browser-level socket when setup failed part-way, turning a setup error into a
hang with a different symptom than its cause.

Also: npx is npx.cmd on Windows, where execFileSync would not have found it and
the failure would have looked exactly like "no stack" — every suite skipping
rather than reporting anything. And share links are built, so they are no
longer in "Not built yet".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
`isOp` let `{ t: 'set', id: 'a', patch: { id: 'b' } }` through. `apply` assigns
the patch over the object, so the object's id changed while the key in
`objects` and the entry in `order` kept the old one — an object nothing can
address any more, and the old id freed for a later `add` to duplicate.

Not a crash, which is why it is worth a test: the document goes on working and
quietly stops making sense. The test asserts the damage `apply` does when asked
directly, then that the transport refuses to ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/helpers/supabase.js (1)

28-36: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

execFileSync('npx.cmd', ...) still fails on Windows without shell: true.

The explicit npx.cmd selection does not fix the underlying problem. Node's execFileSync requires shell: true to execute .cmd/.bat files on Windows, even when the .cmd extension is given explicitly. Without it, the call throws EINVAL, the catch block converts this to null, and every Supabase-backed suite skips silently on Windows, exactly the failure mode the surrounding comment warns about.

Add shell: process.platform === 'win32' to the options object.

🐛 Proposed fix
     const out = execFileSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['supabase', 'status', '-o', 'json'], {
       cwd: ROOT,
       encoding: 'utf8',
       stdio: ['ignore', 'pipe', 'ignore'],
       timeout: 60_000,
+      shell: process.platform === 'win32',
     });
Does Node.js execFileSync require shell:true to run npx.cmd on Windows even when the .cmd extension is specified explicitly?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/helpers/supabase.js` around lines 28 - 36, Update the execFileSync
options in the Supabase status invocation to include shell: process.platform ===
'win32', while preserving the existing platform-specific npx executable
selection and other options.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/autosave.js`:
- Around line 29-37: The flush() async function does not protect the dirty state
invariant when repository.save() rejects. Wrap the repository.save() call (and
the following dirty assignment) in a try-catch block so that if the save fails,
dirty is set to true before rethrowing the error. This ensures the dirty
invariant—that dirty means the screen document is ahead of the stored one—is
maintained regardless of whether the caller at the public API entry points (such
as lines 174 and 193 in src/app.js) handles the rejection or not.

---

Outside diff comments:
In `@test/helpers/supabase.js`:
- Around line 28-36: Update the execFileSync options in the Supabase status
invocation to include shell: process.platform === 'win32', while preserving the
existing platform-specific npx executable selection and other options.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 15f82fd8-a790-4f8c-9b19-f7e88bae7341

📥 Commits

Reviewing files that changed from the base of the PR and between 70469e6 and f97f0b1.

📒 Files selected for processing (9)
  • README.md
  • src/app.js
  • src/core/autosave.js
  • src/platform/auth.js
  • supabase/migrations/20260804193000_board_invites.sql
  • test/db/rls.test.js
  • test/helpers/cdp.js
  • test/helpers/supabase.js
  • test/node/sharing.test.js
🚧 Files skipped from review as they are similar to previous changes (6)
  • supabase/migrations/20260804193000_board_invites.sql
  • README.md
  • test/node/sharing.test.js
  • src/platform/auth.js
  • test/db/rls.test.js
  • src/app.js

Comment thread src/core/autosave.js
Janacek and others added 3 commits August 4, 2026 16:44
Only the debounced wrapper set `dirty` on a rejection, so the two callers that
use `flush()` directly — the replay after a load, and the save on taking over
write authority — left a board that had failed to write looking clean. Nothing
would then retry it, which is the whole point of the flag.

It is set inside `flush` now and rethrown, so a caller that awaited the write
still learns it failed while the retry is armed either way. The debounced path
swallows, as before: it fires on every settled edit with nobody waiting.

Tested through both paths, and for a refused write as well as a rejected one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
Two findings that were malformed on their first posting and re-stated as real
ones.

readSupabaseConfig accepted any non-empty URL, and every request the client
makes carries the user's access token — so an `http:` project URL puts that
token on the wire in clear text. It now parses the URL and requires https,
with loopback the exception because there is no wire: that is the stack on
this machine, which is how the tests and `npm run dev` reach it. A malformed
URL is refused for the same reason a missing one is — the app runs on Web
Storage instead, which is a working app rather than one making requests nobody
can vouch for.

And the node suites were assembling their own Supabase client, which is both a
vendor import the test rules do not permit outside test/helpers/vite.js and a
test of a client the app does not use. They go through createSupabaseClient
now, so the adapter under test is the one that ships — and the URL guard above
covers them too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
Three corrections to "Not built yet". A refused save is retried now — on the
next settled edit, which is the part worth stating, because a board whose last
edit was refused and then left alone still holds changes that are not stored.
The confirmation-link flow and the unenforced repository contract were both
raised in review, deferred deliberately, and were nowhere in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RLXDDxiJBHFCCMR9uekuN
@boubou666
boubou666 merged commit 2b52098 into main Aug 4, 2026
2 checks passed
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.

2 participants