Tool Data Sync
Account-linked storage for the tools' own saved work. What you save in them — a
favourited video, a practice round, a scouted opponent, a judge's ballot
history, a scored card, a flow annotation, a prep note, a coaching program — is
kept on your account and follows you to another device, instead of living in one
browser's localStorage and vanishing the moment you open the app somewhere
else.
It started with the Coaching and Practice tools and now covers the whole sidebar: the research and team stores, and the video library's favourites and hidden list. Two things changed to make that breadth practical, and they are what most of this note is about — the sync no longer needs each tool's package to be wired for it, and a signed-out user is now told their work is browser-only at the moment they save it.
This closes the "per-browser localStorage, not a shared team resource / not account-synced" Known gap that Judge Profiles, Opponent Team Profiles, Flow Annotations, Prep Notes, Coaching Programs, Coach Materials' conversation history, Pre-Round Briefings, Practice Round Simulator, Flow Summaries, Judge Paradigm Selections and the Argument Tree Outline each carried, and extends the pattern flows, rounds, word-count rounds, drill sets and standings already had.
- Route: none of its own, and no UI of its own either. The sync runs
app-wide, in the background.
/settings→ Account → Tool data used to list every collection's state and offer a Sync now button; that page is the card editor's settings now (see User Settings), so that list and button moved to/toolsinstead (ToolSyncStatusPanel, see Known gaps) —/settingslinks to/toolsfor anyone looking for them there. - Package:
debate-data-sync(the catalog, the fetch client, the mirror),apps/debate-ai.com(/api/tool-records/[collection], thesaved_tool_recordsD1 table,lib/hooks/useToolRecordSync.ts,lib/sign-in-prompt-preference.ts,lib/sign-in-prompt-cooldown.ts,components/layout/ToolRecordSyncProvider.tsx,components/layout/SignInPromptProvider.tsx). Some tool packages (debate-round,debate-practice-rounds,debate-speech-writer,debate-team-collaboration,debate-videos) additionally call the mirror from their own stores; most do not need to — see Syncing without wiring.
Which tools sync
One entry per localStorage store, from
debate-data-sync/src/state/toolRecordCollections.ts's
TOOL_RECORD_COLLECTIONS — the list itself is the source of truth, and it is
long enough now that the table below groups it rather than restating every row.
Each entry's section field (one of TOOL_RECORD_SECTIONS) names the same
group, which is what the table below is ordered by. It was the heading
/settings' sync-status list rendered, and outlives that list as the
catalog's own grouping.
Videos (/videos) — debateVideosFavorites and debateVideosHidden, both
keyed by videoId, debateVideoReports keyed by id, and the watch page's
cached AI outcome-simulation runs (speechOutcomeRuns) keyed by a derived
${videoId}::${speechKey}::${lens} id.
Practice — the Practice Round Simulator (practiceRounds), Pre-Round
Briefings (preRoundBriefings) and Debate Versus AI (aiVersusRounds), each
keyed by roundId; your own round history (ownRoundHistory) by id.
Scouting and judging — Opponent Team Profiles (opponentTeamProfiles, by
teamId) and the scouted rounds they aggregate (opponentRoundRecords, by
id); Judge Profiles (judgeProfiles, by judgeId) and the ballots they
aggregate (judgeRoundRecords, by id); the Judge Paradigm Picker
(judgeParadigmSelections, by roundId) and vulnerability reports
(vulnerabilityReports, by roundId); the Opponent Persona Picker
(opponentPersonaSelections, by sessionId).
Flowing and writing — Speech Summaries (flowSummaries), the Argument
Tree Outline (argumentTrees) and its per-round filter selection
(argumentTreeFilters), by roundId; Prep Notes (prepNotes), their
replies (prepNoteReplies) and assignment notifications
(prepNoteNotifications), Flow Annotations (flowAnnotations), the
CardMirror editor's personal spellcheck dictionary
(spellcheckDictionary), the Debate Flow workspace's auto-saved
undo/version history (flowHistory), and the Debate Docs workspace's open
chat tabs (docsChatTabs, the tab layout and titles only — the chat content
itself is the third-party research-agent-ui package's own backend), and the
same workspace's configured storage backends (fileSources — which SSH, S3,
R2, B2, Google Docs or Turso sources are set up, but not what would let
another device connect as this one: see Redacted fields below), all by
id.
Coaching — AI Coach Mode's history (coachConversation), Coaching Programs
(coachingPrograms), the Coach Workspace's current coaching session
(coachingSessions) and its session history (coachingSessionHistory), and
the Flow Edit Log (flowEdits), all by id.
Research — the Evidence Library (evidenceLibraryEntries), card scores
(cardScores) and their history (cardScoreHistory), the Contributions Feed
(contributions), the Argument Library (trackedArguments), card revision
history (revisionHistory), reuse checks (reuseCheckHistory) and Topic
Coverage snapshots (topicCoverageSnapshots), all by id; the Review Queue
(peerReviews) and LLM Card Scoring's AI
assessments (aiCardAssessments), both by cardId.
Team — Team Brainstorm Assist (brainstormIdeas), the Prep Room checklist
(prepRoomChecklist), notes (sprintNotes) and whiteboard
(sprintWhiteboardNotes), Group Challenges (groupChallenges) and their
recorded win events (challengeWinEvents, by a derived id — see below),
Daily Quests (dailyQuestTemplates) and their quest-competition teams
(questTeams), Quest Streaks' day-by-day mission-result history
(dailyMissionResults, by a derived ${contributorId}::${dayKey} id — see
below; Quest Streaks covers the separate,
signed-in-visitor-only sync for its freeze/reminder preferences), award
nominations (contributorAwardNominations), and Progress Unlocks'
celebration-toast "last-seen badges" baseline (unlockCelebrations, by the
contributor's id), all by
id; frozen Daily Best Card winners (dailyBestCardAnnouncements) and Top
Contributor Award standings (contributorAwardAnnouncements), both by
dayKey; the Task Inbox (routedTaskQueues) by topicId and its pending
verification queue (pendingTaskVerifications) by a derived id; contributor
flows (roundContributorFlows) and availability (contributorAvailability)
by contributorId; completed research-task history
(completedResearchTasks) by id.
Practice Drills (saved_drill_sets), Word-Count Speeches
(saved_word_count_rounds), Round Pairings (saved_round_pairings),
Scout-to-Strategy (saved_strategy_recommendations), the AI Judge Decision
(saved_judge_decisions), the Response-Outcome Charts
(saved_counsel_panel_assessments), the Opponent Persona Picker's custom
library (saved_custom_opponent_personas), Coach Materials and their
versions (saved_coach_materials / saved_coach_material_versions), the Prep
Room's sprints (saved_sprint_sessions) and daily best-card comments
(saved_daily_best_card_comments) all sync too, but each through its own
dedicated table and route rather than this one — see the next section.
TOOL_RECORD_COLLECTIONS is an allowlist, not a convenience: the route
rejects any collection key not in it, so the table can't be used as a
free-form per-user blob store by a caller inventing names.
What deliberately does not sync
The requirement is a shape, not a preference: a JSON array under one
localStorage key whose elements are objects carrying a stable string id. So
these stay local on purpose —
- Single-object settings stores (
myTeamProfile,fontFamily, theebbkeymap and display settings). There is no per-record id to key them by, and they are mostly per-device choices anyway. - Presence heartbeats (
flowPresenceHeartbeats,topicPresenceHeartbeats) and per-device playback state (persistent-video-player,debate-playback-speed,video-timestamps). These describe this browser, not this user; syncing them would have one device overwrite another's idea of where the video is paused. - Undo/redo stacks (
judgeRoundRecordEditHistoryand friends). What syncs is the current version of a record. Fixed:coachingSessions, the Coach Workspace's current session per round and side, kept only by(roundId, sideKey)with no single id field to key a row by. Giving the record its own id is what it would take to sync directly.saveCoachingSessionnow stamps every record with a derived${roundId}::${sideKey}id, so the current session per round+side syncs alongside its own version history (coachingSessionHistory).Fixed:dailyMissionResults, Quest Streaks's day-by-day contributor mission-result history, kept only by(contributorId, dayKey)with no single id field to key a row by — the same shape problemcoachingSessionshad.saveDailyMissionResultnow stamps every record with a derived${contributorId}::${dayKey}id, so a contributor's streak/badge history follows them to another device.Fixed: the store now persists an array ofunlockCelebrations, Progress Unlocks' per-contributor "last-seen badges" celebration-toast baseline, kept as a plainRecord<contributorId, string[]>map rather than an array of records — no per-record id to key a row by, the same shape gap every collection here started with.{ id, badges }records (idthe contributor's id, migrating any pre-existing legacy map on read), so a badge already celebrated on one device isn't celebrated again as "new" on a second one.
Redacted fields
One collection can't join with a plain catalog entry the way the rest do:
fileSources (/doc's file browser, REASON-file-sources) has the required
shape, but its records can carry an SSH password or private key, an S3/R2/B2
secret access key, or a Google OAuth refresh token
(fileSource-types.ts's SSHCredentials/S3Credentials/R2Credentials/
B2Credentials/GoogleDocsCredentials) — and the route stores whatever JSON
a collection hands it verbatim, with no redaction of its own. A plain entry
would put those secrets into saved_tool_records unencrypted.
So ToolRecordCollection carries one more, optional field: redact, a pure
(record) => record applied at every push (the immediate mirror, the
auto-sync watcher, and the first-sign-in push of local-only records) and
never to what a tool reads back out of its own localStorage. fileSources'
is redact-file-source.ts's redactFileSource — an allowlist per source
type (host/port/username for SSH, region/bucket for S3, …), not a blocklist of
secret-looking names, so a credential field added to fileSource-types.ts
later defaults to held back rather than defaulting to synced. A source's
connection details follow you to another device; what would let that device
connect as you does not, and has to be re-entered there.
Redacting on the way up only works if the merge, on the way back down,
doesn't read the account's now-missing field as the account clearing it —
mergeToolRecords' restoreRedactedFields fills back whatever the local
copy has that the remote copy doesn't, recursing into credentials itself,
so the password stays on the device that saved it instead of being wiped out
by the record's own next reconcile.
Every other collection has no redact, and none needs one: this is a fix for
the one collection whose shape happens to also carry a credential, not a step
every collection has to take.
Tools already backed by a saved_* table of their own — flows, rounds,
tournament results, drill sets, word-count rounds, round pairings,
scout-to-strategy, AI judge decisions, response-outcome charts, custom
opponent personas, coach materials and their versions, prep-room sprints, and
daily best-card comments — keep those. This table is for the stores that had
nothing. (Eleven of those once had a redundant entry here too — reconciled
through both their own table and this one, with nothing ever reading the
second copy back. Removed rather than fixed forward, since nothing pointed at
the generic copy in the first place.)
Local-first, by design
Each tool still reads and writes its own localStorage store directly and
stays fully usable signed out. The account is a mirror kept up behind it,
not a replacement for it — the same rule every other synced store in this
repo follows ("local apply is never blocked by a sync failure"):
- A local save applies immediately; the upload is fire-and-forget.
- A failed upload is swallowed, never surfaced as a broken save.
- A
401(a session that expired mid-visit) switches mirroring off rather than retrying every subsequent write against an endpoint that will refuse.
Data flow
state/toolRecordCollections.ts (pure — no fetch, no localStorage)
→ TOOL_RECORD_COLLECTIONS the allowlist: key, storageKey, idField,
label, href
→ toolRecordId / isSyncableToolRecord
what a syncable record is (an object with
a non-empty string at `idField`) —
shared by the route and the client
→ mergeToolRecords local ∪ remote, account wins per id
→ toolRecordsMissingRemotely the local-only records a sign-in pushes up
state/tool-records-client.ts (fetch only)
→ listToolRecords GET …/[collection] (null on 401)
→ listAllToolRecords GET /api/tool-records
every collection at once (null on any
failure, so a dropped request is never
mistaken for an empty account)
→ saveToolRecordToAccount PUT …/[collection]/[recordId]
→ saveToolRecordsToAccount PUT …/[collection] (bulk)
→ deleteToolRecordFromAccount DELETE …/[collection]/[recordId]
→ clearToolRecordsInAccount DELETE …/[collection]
state/tool-record-mirror.ts (the glue the tool stores call)
→ setToolRecordSyncEnabled off until the app says otherwise
→ mirrorToolRecordSave/Delete/sSave/sClear
fire-and-forget, never throws
→ readLocalToolRecords / writeLocalToolRecords
a collection's store, by storage key
→ hydrateToolRecords the account merge for one collection
→ beginToolRecordPrefetch / endToolRecordPrefetch
brackets a reconcile so all its merges
share one bulk GET
state/tool-record-auto-sync.ts (the floor: no per-package wiring needed)
→ markToolRecordsSynced baseline a collection post-merge
→ flushToolRecordCollection diff one store vs. its snapshot and push
→ flushToolRecords the whole catalog, sequentially
→ startToolRecordAutoSync interval + storage + pagehide triggers
→ resetToolRecordAutoSync forget every snapshot (sign-out)
state/sign-in-prompt.ts (framework-free; no React, no fetch)
→ setSignedIn / isSignedIn the same flag the mirror follows
→ requireSignIn true, or false having raised a prompt
→ subscribeToSignInPrompts what the app's dialog host listens on
apps/debate-ai.com
→ lib/hooks/useToolRecordSync.ts enables the mirror and the guest prompt
for a signed-in session; reconciles
every collection once per tab; then
starts the watcher
→ components/layout/ToolRecordSyncProvider.tsx
mounts that hook, renders nothing
→ components/layout/SignInPromptProvider.tsx
opens LoginDialog for a raised prompt
→ app/api/tool-records/route.ts GET (every collection)
→ app/api/tool-records/[collection]/route.ts GET / PUT (bulk) / DELETE
→ app/api/tool-records/[collection]/[recordId]/route.ts PUT / DELETEWhy one table instead of one per tool
The stores all have the same shape — a JSON array under one
localStorage key, each record identified by one string field — so they share
one saved_tool_records table keyed by (user_id, collection, client_id) and
one route pair, rather than the file set per feature that
saved_flows/saved_drill_sets/saved_tournament_results each took. data
holds the whole record JSON-stringified, mirroring those tables'
blob-column approach: a record is read and written as one unit by the tool
that owns it, and its fields are that tool's business rather than the
table's.
Syncing without wiring
The original design synced a store by having its own save*/delete*
functions call mirrorToolRecord*. That is precise, immediate, and the right
thing for a store whose writes all funnel through one pair of functions. It
does not scale: the tools keep around ninety localStorage stores between
them, written from panels, hooks, CSV importers, undo stacks and each other
(logging a judge's round rebuilds that judge's derived profile, so one call
mirrors into two collections). Wiring each of those call sites by hand is both
a very large mechanical change across a dozen packages and a standing
invitation for the next call site to forget — and a store that forgets is
silently back to being per-browser, with no error anywhere.
So state/tool-record-auto-sync.ts syncs by observation instead. It keeps
a snapshot of each collection's serialized records and, whenever something
might have changed, diffs the store against it and pushes what moved: upserts
for records that are new or whose JSON differs, deletes for ids that are gone.
Adding a tool to the sync is therefore one entry in the catalog, with no
edit to the owning package at all.
It re-checks on a 15-second interval, on a cross-tab storage event, and when
the tab is hidden or unloaded — the last of those being what saves the change
someone made immediately before closing their laptop.
The two mechanisms compose rather than compete. An instrumented store's
mirrorToolRecordSave still goes out the moment the user clicks; the watcher
then sees that record already matching its snapshot and sends nothing. The
watcher is the floor, not the path.
Three properties it must have, all of them pinned by
test/tool-record-auto-sync.test.ts, because each fails silently:
- It baselines after the merge, not before.
useToolRecordSynccallsmarkToolRecordsSyncedper collection as that collection finishes reconciling. Without it, every sign-in would push the entire account back at itself; doing it after the whole loop instead would let a save made mid-reconcile be baselined as already-synced and never go up at all. - A failed write does not advance the snapshot. The record is retried on the next tick rather than quietly forgotten — the one bug a sync like this cannot be allowed to have.
- A sign-out resets the snapshots. They described the previous account; kept, the next user's first flush would push nothing.
One record that cannot be sent is not allowed to cost its collection the
flush. The route rejects an entire bulk PUT if any record in it is over
MAX_TOOL_RECORD_BYTES, so an oversized record is held back from the batch
and baselined anyway — otherwise it would take every other changed record in
that collection down with it, on every tick, forever. If it is later edited
back under the cap its JSON changes and the next flush picks it up.
Two properties make all of this safe to call from an otherwise pure state module:
- Off by default. Nothing syncs until
setToolRecordSyncEnabled(true), which onlyToolRecordSyncProvidercalls, and only onceuseSessionreports a signed-in user. A signed-out browser, a server render and a unit test therefore make no requests at all. - Never throws, never blocks. A mirror call returns immediately and swallows its own failure.
Telling a guest
The tools being usable signed out is deliberate, but it had quietly become "favourite fifty rounds on your laptop, open your phone, find an empty library". The work was real; the guest just had nowhere to keep it, and no way to find that out until they lost it.
So a store can now raise a prompt at the click. state/sign-in-prompt.ts is a
plain subscribe/publish pair — no React, no next/*, no fetch — because the
sign-in UI lives in the app and debate-data-sync is a leaf package that
cannot import it. A tool publishes an intent naming the feature and one
sentence about what signing in keeps; components/layout/SignInPromptProvider.tsx
subscribes and opens LoginDialog with that text, returning the user to the
page they were on. It reads the same signed-in flag the mirror does, so the two
can never disagree about whether there is an account to save to.
Three rules keep it an offer rather than a gate:
- The local save still happens.
requireSignInreturning false does not mean the caller skipped the write — a guest who dismisses the dialog keeps the favourite they just made, and the first sign-in pushes it up, because the merge treats a local-only record as something to adopt rather than discard. - It asks once per feature, not once per click. Favouriting is a rapid,
repeated action, and a dialog on every one of a dozen clicks is not a
helpful offer — it is a tool that has stopped working. The provider keeps a
30-minute per-feature cooldown in
localStorage(lib/sign-in-prompt-cooldown.ts), so it survives across this browser's tabs rather than resetting every time a guest opens a new one — asessionStorageversion of the same cooldown used to reset that way; see Known gaps for what it still doesn't cover. Removing something never prompts at all: un-favouriting and un-hiding are not moments to ask anyone to sign in. - A guest can say "don't ask me again", and it sticks. The dialog's
secondary action calls
setSignInPromptOptedOut(true)(lib/sign-in-prompt-preference.ts), a plainlocalStorageflag the provider checks before raising any prompt — unlike the cooldown above, this never expires on its own, on purpose. Nothing surfaces or reverses it any more: the row that showed a signed-out visitor the current state and offered a button to flip it back was on/settings, and the dialog that could otherwise change their mind never shows again once they've opted out (see Known gaps).
The merge
On the first page load of a signed-in tab, useToolRecordSync fetches the
whole catalog in one GET /api/tool-records and then reconciles each
collection in turn against its slice of that payload. The bulk read matters at
this size: a GET per collection would be dozens of round trips, each its own D1
read, fired while the page is still painting — and the rows all live in one
table behind one index on (user_id, collection). A prefetch that fails falls
back to per-collection fetches rather than being mistaken for an empty account,
which would have the merge adopt nothing and the push re-upload everything.
Then, per collection:
- A record the account has and this browser doesn't is adopted locally.
- A record both hold resolves to the account's copy — it is the shared truth across devices.
- A record only this browser has is pushed up, in one bulk request. A first sign-in is not a reset: nothing local is dropped.
- A local record with no usable id can't be keyed, so it is left alone rather than discarded.
Records are create/replace/delete — never edited field-by-field from two
devices at once — so this is a union rather than a conflict resolution, the
same rule useStandingsAccountSync's tournament-result
merge follows.
Writing the merged records back to localStorage also dispatches a synthetic
storage event. The panels already refresh on that event (see each package's
live-update.ts), but the browser fires it only in other tabs — never in
the one that wrote — so dispatching it by hand is what makes an account merge
show up in the open tool without every panel needing to know this sync exists.
The merge is deduped per tab through a sessionStorage stamp with a 10-minute
TTL, because the shell document and each framed dock destination both mount
the provider (a page's tool panels run inside the frame, and the mirror's flag
is module state belonging to whichever document imported the store).
The watcher starts only once the merge has finished and baselined every store. Running it during hydration would race the merge's own writes and push back records it had just adopted.
Seeing it
Nothing shows it now. /settings → Account → Tool data listed every
synced tool with a link to it, grouped under the same section headings as the
table above, and said plainly which state you were in — "This browser" while
signed out, "Syncing…" then "Synced" (or "Not synced" for a collection whose
merge failed) once signed in — with a search box over the list and a Sync
now button. That page is the card editor's settings now, and the list, its
search box and the button went with it.
What it reported is all still computed: useToolRecordSync returns the same
per-collection results, and syncToolRecords is still what a Sync now
would have called. Only the surface is gone, so a failed merge, a
browser-only record over the size cap, and a guest's sign-in opt-out are
each invisible until something renders them again — see Known gaps.
Tests
packages/debate-data-sync/test/toolRecordCollections.test.ts— the catalog (unique keys, every field filled, the allowlist refusing an invented key) and the merge rules, including that a first sign-in never drops a local-only record.packages/debate-data-sync/test/tool-record-mirror.test.ts— the two properties above: no request at all while disabled, and a swallowed failure (plus the401stand-down) rather than a rejection into the calling store.packages/debate-data-sync/test/tool-record-sync-wiring.test.ts,packages/debate-round/test/tool-record-sync-wiring.test.ts,packages/debate-practice-drills/test/tool-record-sync-wiring.test.ts,packages/debate-speech-writer/test/tool-record-sync-wiring.test.ts,packages/debate-team-collaboration/test/tool-record-sync-wiring.test.ts— that each store actually calls the mirror. The mirror is invisible from inside a store, so dropping the wiring breaks nothing the store's own tests would notice.packages/debate-data-sync/test/tool-record-auto-sync.test.ts— the watcher: it pushes what changed, deletes what went, sends nothing when nothing moved, retries a failed push instead of losing it, stands down on a401, resets on sign-out, and holds back an oversized record without costing its collection the flush.packages/debate-data-sync/test/tool-record-catalog.test.ts— the catalog's own integrity, which is now the only thing needed to make a tool sync: a duplicatedkeywould merge two tools' rows, and a mistypedidFieldwould make every record in that store fail validation, leaving the tool working locally and never syncing, with no error anywhere.packages/debate-data-sync/test/sign-in-prompt.test.ts— the prompt bus, including the property that is easiest to "tidy up" into a bug: a guest's local save still happens.apps/debate-ai.com/lib/__tests__/sign-in-prompt-preference.test.ts— the persistent opt-out: defaults to "ask", persists across reads, clears, and never throws whenlocalStorageis unavailable or refuses the read/write.apps/debate-ai.com/lib/__tests__/sign-in-prompt-cooldown.test.ts— the 30-minute per-feature cooldown: unshown defaults to false, true immediately after marking and up to the boundary, false once the window elapses, each feature tracked independently, and the same never-throws guarantee as the opt-out above.apps/debate-ai.com/lib/tools/__tests__/tool-sync-status.test.ts—ToolSyncStatusPanel'ssummarizeToolSyncFailures: a genuine failure surfaces with its tool's label and link, a signed-out-shapedsynced: falsewith noerroris not mistaken for one, a stale collection key is dropped, and multiple failures sort by label.packages/debate-webview/test/lib/tools/tool-sync-summary.test.ts—ToolSyncSection'ssummarizeToolSyncStatus: the "syncing" sentence while unreconciled regardless of a stale failure count, "all N tools" once reconciled with none, and singular vs. plural failure wording.packages/debate-videos/test/video-library.test.ts— favourites, hidden videos and reports, with the legacy bare-id format cases: a store written in the old format still reads, keeps its order, de-duplicates against the new format, and is dated to the epoch rather than to the upgrade (which would reorder a season's collection).packages/debate-videos/test/tool-record-sync-catalog.test.ts— every collection'shrefis a destination the sidebar links to, so a catalog entry can't point at a 404.
Known gaps
- Still per-user, not per-team. A squad's judge profiles and prep notes sync
to the account that wrote them; sharing them across a team would need the
sharing model
card_shares/contactsuse, not just this table. - No identity/permission check on who may log a round for a judge or against a team beyond "it's your own account's data" — the same gap Judge Profiles and Opponent Team Profiles already note.
- The merge runs once per tab (10-minute TTL), and that is now the only time
it runs — Sync now was on
/settings' removed Tool data list. There is no push channel either, so a record saved on another device mid-session shows up on the next reload rather than live, and a reload is the only way to ask for it. The syntheticstorageevent covers other tabs of the same browser only. - The watcher is a poll, so an uninstrumented store's change reaches the
account at the next tick (up to 15 seconds, or immediately when the tab is
hidden or closed) rather than on the click. A store that also calls
mirrorToolRecord*— the video library, and the Practice/Coaching stores wired before the watcher existed — does not wait. Nothing is lost either way; the difference is only latency. - A record edited to an identical value is not re-sent. The diff is on serialized JSON, which is correct for these create/replace/delete stores but would not be for one that needed a "touched at" signal.
- Edit-undo/redo stacks (
judgeRoundRecordEditHistoryand friends) stay local: what syncs is the current version of a record, so an undo performed on one device is not replayable on another. - A record over
MAX_TOOL_RECORD_BYTES(200 KB) never reaches the account: the route refuses it with a 413, and the watcher holds it back from the batch so it cannot take its collection's other records down with it. It still saves locally, and it syncs if later edited under the cap — but until then it is browser-only.ToolSyncStatusPanel(/tools, see below) shows it as a sync failure with the 413's own message, including when the watcher finds it on a background tick rather than during the tab's initial reconcile (startToolRecordAutoSynctakes anonFlushcallback anduseToolRecordSyncfolds its results into the sameresultsstate the reconcile populates). A bulk push of more than ~50 records answered a bare 500.Fixed:PUT /api/tool-records/[collection]awaited one upsert per record, and each spends one of D1's per-invocation queries (50 on the Workers Free plan), so a long-lived store — adocsChatTabslist of a hundred-odd tabs — failed mid-loop after several seconds. The upserts now go out throughdb.batch()inchunkStatementsruns (lib/database/query-budget.ts), a few queries for even a full 500-record push.Nothing surfaces the sync any more, and two things can only be reached through it.Fixed:components/tools/ToolSyncStatusPanel.tsx, mounted on/tools, rendersuseToolRecordSync'sresultsas a list of any collection whose merge or flush failed (name, link to the tool, the error), next to a Sync now button wired toresync(); and when a guest has opted out of the sign-in prompt, a "Turn sign-in reminders back on" button callingsetSignInPromptOptedOut(false). Both controls, and the state behind them, already existed — this is/settings' old Tool data list re-mounted at/toolsinstead, per this entry's own prior suggestion./settings' Preferences tab now also has a compact link back:components/settings/ToolSyncSection.tsxrenders the sameresultsas one status sentence (lib/tools/tool-sync-summary.ts'ssummarizeToolSyncStatus) and a link to/tools, so the sync is discoverable from Preferences without duplicating the failure list or the Sync now button there.The 30-minute per-feature cooldown is per-browser-session, not per-account. It lives inFixed:sessionStorage, so a user who dismisses a prompt without opting out entirely is asked again about that feature in a new tab.lib/sign-in-prompt-cooldown.tsmoved the same bookkeeping tolocalStorage, which a browser shares across tabs, so the cooldown now survives opening a new one — the actual bug this entry described. "Per-account" was never quite the right framing: the prompt only ever shows to a guest, who by definition has no account row to key a cross-device cooldown against; the moment they sign in the prompt stops entirely (SignInPromptProvider's second effect clears it). So this is cross-tab, same-browser persistence, matching the "don't ask me again" opt-out it sits next to — which already worked this way. (A guest who picks "don't ask me again" instead still gets that separate, non-expiring opt-out described above.)- Audited, stays separate:
TOOL_RECORD_COLLECTIONSdoes not derive fromapp/tools/tool-groups.ts'sALL_TOOLS, unlike/tools, the favorites system, and the command palette. This is deliberate, not an oversight: it is an opt-in allowlist of the specificlocalStoragestores that still had the "per-browser, not account-synced" gap and happen to share one shape (a JSON array keyed by one string id field) — not a list of every tool, and severalALL_TOOLSentries correctly have no row here at all (they're backed by their ownsaved_*table, like flows and rounds, or they persist nothing). Deriving it fromALL_TOOLSwould either pull in tools that can't be keyed this way or silently drop the shape check this table's safety depends on, so it keeps its own list. See Unify the four hand-maintained tool catalogs for the sibling catalogs that do now cross-check each other.
