Skip to content

Move Save and Save As into the host - #475

Merged
alex-rawlings-yyc merged 10 commits into
mainfrom
433-host-save
Oct 22, 2026
Merged

alex-rawlings-yyc merged 10 commits into
mainfrom
433-host-save

Conversation

@jasonleenaylor

@jasonleenaylor jasonleenaylor commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #433. Part of Phase 1 (#424) of the data layer plan, step 1d.

Summary

Save and Save As no longer send the analysis over the bus. The WebView settles its draft writes, then one host command copies the stored draft into the interlinear project and replies with the project's summary. saveAnalysis is removed.

WebView: useDraftProject.commitDraft(copy)
  writer.settle()                     # send any scheduled write, wait for every write in flight,
                                      # send a write that failed once more
  draft replaced meanwhile?  -> false, nothing saved
  copy(snapshot)                      # Save:        saveDraftToProject(source, id)
                                      # Overwrite:   saveDraftToProject(source, id, { analysisLanguages, targetProjectId })
                                      # Save As new: saveDraftAsProject(source, languages, target, name, description)
  copy landed whole?  -> mark synced unless an edit landed during the copy
  copy left books out -> draft stays unsaved

Host: saveDraftToProject / saveDraftAsProject
  exportForCopy(source)               # names books whose stored analysis cannot be read
  any unreadable?  Save -> refuse, project untouched; Save As new -> leave them out
  updateAnalysis(...) / persistNewProject(...)
  markClean(source)                   # envelope only, skipped if a book was left out
                                      # or a draft write followed the read
  -> Save: project summary JSON, or undefined when the project is gone
     Save As new: { project, omittedBooks } JSON

Save As to a new project creates and fills the project in one call, so a failure leaves no empty project behind, and reports its failure as a failed project creation. A Save whose project no longer exists says so.

While a save runs, the toolbar shows "Saving…", and New, Open, Save, Save As and Wipe send an info notification and do nothing, so the draft the copy reads is the one the user chose to save.

src/utils/debounced-writer.ts owns the autosave timer, the latest write and its failure. Its write must land writes in the order they are sent, which the host's per-source draft queue does.

Unreadable draft books

A draft book whose stored analysis cannot be read (a torn write or outside damage) is kept as stored and never written over:

draft loads                           -> user warned which books
draft write carrying analysis for it  -> rest saved, that book left out; user told once,
                                         again after a write leaves none out or a reload
Save to an existing project           -> refused, project untouched; user told the books and the way out
Save As to a new project              -> book and its boundaries left out; user told which; draft stays unsaved
New / Open                            -> discard it (saveDraft's discardUnreadable), which is how the user recovers

Changes to 1b's repository

@imnasnainaec, for the conformance suite in #469:

  • replaceAll(draft, options) resolves with the books it left out. A book its load could not read keeps its shard as stored, whatever the draft carries for it, until discardUnreadable says the draft was started over. In 1b, a save carrying analysis for such a book wrote over its shard.
  • replaceAll no longer rejects once the draft has landed: a failed orphan-shard deletion or journal update is logged, and a later save retries it.
  • exportAll resolves with the draft and the books it could not read.
  • AnalysisRepository gains exportForCopy, which reads as exportAll does but rejects an unreadable envelope, and markClean, which rewrites only the envelope.

Evidence

  • Before: Save sent the whole TextAnalysis in saveAnalysis (74.9 MB at the largest perf tier, per the Phase 1 baseline in docs/data-layer-plan/plan.md).
    After: Save sends two ids, and no draft write follows it. The perf harness times saveDraftToProject in place of saveAnalysis and still records the request as save-bytes.
  • Tests cover, among others:
    commitDraft: pending autosave written before the copy; edit during settle written too
    commitDraft: failed write sent again -> copy runs; fails again -> rejects, no copy
    commitDraft: draft replaced while settling, or a second save -> false, no copy
    Loader: save in flight -> New / Open / Save / Save As / Wipe notify and do nothing
    projectStorage: unreadable book -> Save refused; write leaves it out; Open from a project -> Save works
    projectStorage: stored draft marked clean, unless a book was left out or a draft write followed the read
    Save As leaving books out -> draft stays unsaved
    draftRepository: cleanup failure after the draft lands -> resolves, logged, retried
    DebouncedWriter: a value written again and failing again is retried, not rejected at once
    
  • Unit, lint and e2e CI pass.

Merge Danger

Door: two-way

No storage format changes; markClean writes the existing envelope with dirty: false. The one irreversible path is New or Open discarding a draft book's unreadable shard, which only damaged data reaches.

Blast Radius: Save

Save, Save As and autosave all change path. Replacing saveAnalysis with two commands and adding discardUnreadable to saveDraft changes the command surface 4a (#453) builds on. The repository interface grows, so #469 needs cases for it.

🤖 Generated with Claude Code


This change is Reviewable

@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 5e8aa5dc-ecc0-448a-a717-8740cfee5c0b
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These comments were generated by Claude Opus 5.5 (Claude Code) and reviewed by me before posting.

Comment thread contributions/localizedStrings.json Outdated
Comment thread src/__tests__/components/InterlinearizerLoader.test.tsx Outdated
Comment thread src/__tests__/components/InterlinearizerLoader.test.tsx Outdated
Comment thread src/__tests__/utils/debounced-writer.test.ts Outdated
Comment thread src/hooks/useDraftProject.ts Outdated
Comment thread src/hooks/useDraftProject.ts Outdated
Comment thread src/main.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
jasonleenaylor and others added 2 commits October 20, 2026 10:14
Save and Save As no longer send the analysis over the bus. The WebView
settles its draft writes, then one host command copies the stored draft
into the interlinear project and replies with the project's summary.
saveAnalysis, which carried the whole analysis both ways, is removed.

Three commands become two: saveDraftToProject covers Save and Save As
onto an existing project, taking the draft's configuration as one
object so that overwrite sets the project's languages and target in the
same write it used to need a second call for; saveDraftAsProject covers
Save As to a new project, creating and filling it in one call where a
failed second call used to leave an empty project behind. Each reports
its own failure, so a failed Save As to a new project no longer reads
as a failed save of an existing one.

useDraftProject owns the save end to end through commitDraft: it
settles the draft's writes through a DebouncedWriter, which sends any
scheduled write, waits for every write in flight, and sends a failed
write once more, so the stored draft is the one in memory before the
host copies it; it then runs the copy and marks the draft synced only
when the copy landed. A save started while another runs does nothing,
and a draft replaced while the writes settle is not saved. While a save
runs, the toolbar shows it and New, Open, Save, Save As, and Wipe tell
the user to wait, so the draft the copy reads is the one the user chose
to save. A Save whose project no longer exists says so.

The copy reads the stored draft strictly: a damaged shard or envelope
fails the save instead of writing an emptied book into the project. A
wiped book's shard that cannot be deleted no longer fails the draft
write that stored everything else; it stays journaled for a later save
to retry, so one stuck deletion cannot fail every Save after it.

The perf harness times saveDraftToProject in place of saveAnalysis and
records the request size as the bytes sent, so the Phase 1 checkpoint
keeps a number to compare against the 74.9 MB baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Save no longer sends the draft again once the copy lands. The host
marks the stored draft clean itself, rewriting only its envelope, and
only when no draft write reached it after the copy read the draft. The
WebView flips its own flag and writes the clean draft only when a write
went out during the Save, as an edit undone mid-save does. The perf
harness no longer waits for an autosave after each Save.

A book of the draft that cannot be read no longer fails every Save.
The copy names such books instead of throwing: Save keeps the
project's stored analysis and boundaries for them, Save As leaves them
out, and either tells the user which books. A draft whose envelope is
unreadable still fails, since no book in it can be vouched for, and its
first autosave rewrites the envelope.

AnalysisRepository: exportAll is lenient again, as 1b had it;
exportForCopy reports the books it could not read; markClean records
the stored draft as saved.

The saveDraftToProject command metadata describes its config object
rather than two positional parameters, and commitDraft documents its
rejections with @throws. "Saving..." uses the ellipsis character like
the other progress strings, a test comment no longer names hook
internals, and the gating and flush tests are split per side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These comments were generated by Claude Opus 5.5 (Claude Code) and reviewed by me before posting.

Comment thread src/__tests__/hooks/useDraftProject.test.ts Outdated
Comment thread src/main.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
@imnasnainaec imnasnainaec changed the title Move Save and Save As into the host (#433) Move Save and Save As into the host Oct 20, 2026
A wipe now lets go of a book whose stored draft analysis could not be
read. Before, the draft repository held such a book through every
save that carried nothing for it, Wipe Book and Wipe All included, so
Save kept the project's copy of it and warned on every Save until the
user edited inside it. Each draft write now names the books released
since the draft was loaded: Wipe Book adds its book, and Wipe All, New
and Open release all of them. Releasing a book again is harmless, so
every write carries the whole set and a failed write needs no retry of
its own; a load starts the set afresh, since holds come from a load
and book codes repeat across sources. AnalysisRepository.replaceAll
and the saveDraft command take the released books as an optional
argument.

Save As leaves out the boundaries of a book it could not read along
with its analysis, matching what it tells the user. The unreadable
books warning names analyses linked to no book in words rather than by
their storage name.

A test now covers an edit and its undo that are both written before
the copy lands, the case only the count of writes sent can catch. It
runs on fake timers for its whole body, so each write fires when the
test advances the clock and never on real time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonleenaylor
jasonleenaylor enabled auto-merge (squash) October 20, 2026 21:18

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These comments were generated by Claude Opus 5.5 (Claude Code) and reviewed by me before posting.

Comment thread src/components/modals/ProjectModals.tsx
Comment thread src/hooks/useDraftProject.ts Outdated
Comment thread src/services/draftRepository.ts
Comment thread src/services/projectStorage.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
@imnasnainaec

imnasnainaec commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

From #432: exportForCopy, markClean, exportAll's unreadable books, and replaceAll's discardUnreadable option and returned left-out books need conformance tests. Whichever of #469 and #475 merges second adds them.

Wiping a book whose stored draft analysis could not be read deleted
that analysis, so undoing the wipe could not bring the hold back, and
the next Save emptied the project's copy of the book without a word.
A wipe now changes only the draft: the draft records its wiped books,
Wipe Book adding its book and Wipe All, New and Open recording all of
them, as part of the content the undo history restores. The host keeps
an unreadable book's stored analysis whatever the draft says, stores
the wiped books as those it actually holds unread, and a Save empties
only the ones the draft has wiped. The release argument on saveDraft
and AnalysisRepository.replaceAll is gone.

A failed update of the shard journal no longer fails a draft save
whose content has landed; it is logged, and the next save retries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These comments were generated by Claude Opus 5.5 (Claude Code) and reviewed by me before posting.

Comment thread src/__tests__/services/projectStorage.test.ts Outdated
Comment thread src/services/draftRepository.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
Comment thread src/services/projectStorage.ts Outdated
Stop saving around a draft book whose stored analysis cannot be read,
and refuse instead. Each review round had found another path through
the partial save that kept a project's copy of such a book; the case
needs a torn write or outside damage to arise, so it is not worth that
machinery.

Refuse a draft write that carries analysis for an unreadable book, so
an edit can never hide the damage, and refuse Save to an existing
project while any book is unreadable, so the project is never touched.
Save As to a new project still leaves such a book out, so the rest of
the draft can be kept. New and Open discard the unreadable analysis,
which is how the user recovers; each refusal tells the user which
books and how, once until a write succeeds.

Drop the draft's wiped books, the copy that kept a project's books,
and the clean-draft write after an edit undone during a Save: the
draft simply stays marked unsaved until the next Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonleenaylor

Copy link
Copy Markdown
Contributor Author

@alex-rawlings-yyc, a change of direction on the unreadable-book handling, prompted by your fourth round.

Your finding held, and so did a broader one: with JHN's draft shard damaged, a single gloss in JHN that lands, with no undo at all, makes the next Save replace the project's whole JHN with that one gloss. Looking back over the rounds, 8 of the 9 substantive findings since round one were paths through the same design: saving around a book the draft cannot read, keeping the project's copy, tracking wipes, and so on. Each fix added state the next round found a way around.

So 1d now fails fast instead. A draft write carrying analysis for an unreadable book is refused, so an edit can never hide the damage. Save to an existing project is refused while any book is unreadable, so the project is never touched. Save As to a new project leaves the book out, so other work can still be kept. New and Open discard the unreadable analysis, which is how the user recovers, and each refusal tells them so once until a write succeeds. The case needs a torn write or outside damage, so I would rather it be loud and simple than handled.

That removes wipedBooks, the keep-the-project's-copy merge and its boundaries handling, and the clean-draft write after an edit undone during a Save. An edit undone during a Save now just leaves the draft marked unsaved until the next Save. Tests and source both shrink against the last push. Each new refusal was mutated out and its tests failed.

@jasonleenaylor
jasonleenaylor enabled auto-merge (squash) October 21, 2026 18:56

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These comments were generated by Claude Opus 5.5 (Claude Code) and reviewed by me before posting.

Comment thread contributions/localizedStrings.json
Comment thread src/main.ts Outdated
Comment thread src/services/draftRepository.ts Outdated
@imnasnainaec imnasnainaec self-assigned this Oct 21, 2026
@alex-rawlings-yyc alex-rawlings-yyc self-assigned this Oct 21, 2026
@imnasnainaec imnasnainaec removed their assignment Oct 21, 2026
Comment thread src/main.ts Outdated
@imnasnainaec

Copy link
Copy Markdown
Contributor

@alex-rawlings-yyc e7922198 swaps refusing a draft write that carries analysis for an unreadable book for leaving that book out. The case is rare, but when it happens it usually hits the book being edited: writeUserData is a plain fs.writeFile that isn't atomic, so a crash, force-quit or full disk mid-write cuts short the shard autosave was just rewriting, and that's the book the user is most likely to edit again. Reconsider it. Options:

Keep

Reverse (refuse, as in bbd39264)

  • + All-or-nothing, so it carries straight over to 3b. Dropped edits never look saved.
  • − The trap returns: one edit in the book blocks every later write in every book. If reversed, this PR should also show the refusal on Save and Save As, and change the message to point to undo or wipe as the way out.

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@alex-rawlings-yyc reviewed 24 files and all commit messages, and resolved 25 discussions.
Reviewable status: all files reviewed, 1 unresolved discussion (waiting on imnasnainaec).

Comment thread src/main.ts Outdated
Comment thread src/hooks/useDraftProject.ts Outdated

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@imnasnainaec reviewed 22 files and all commit messages, and resolved 1 discussion.
Reviewable status: all files reviewed, 2 unresolved discussions (waiting on jasonleenaylor).

@imnasnainaec imnasnainaec left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@imnasnainaec reviewed 2 files and all commit messages.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on jasonleenaylor).

@alex-rawlings-yyc alex-rawlings-yyc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@alex-rawlings-yyc reviewed 2 files and all commit messages.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on jasonleenaylor).

@alex-rawlings-yyc
alex-rawlings-yyc merged commit 2760501 into main Oct 22, 2026
10 checks passed
@alex-rawlings-yyc
alex-rawlings-yyc deleted the 433-host-save branch October 22, 2026 14:44
imnasnainaec added a commit that referenced this pull request Oct 22, 2026
The interface now names the books a load could not read, keeps such a
book as stored on save unless told to discard it, settles a save once
its content lands, and adds exportForCopy and markClean. The suite
checks each of those at the seam, so a later backend owes them too.

- ConformanceStore gains hooks to damage one book or the whole draft,
  fail a save's cleanup after its content lands, and report leftovers.
- The DraftRepository runner tolerates warnings only in tests that
  damage storage or fail cleanup.
- DraftRepository tests the suite now covers are dropped; those that
  check which keys are written or what bytes stay remain.
- exportForCopy's doc names the empty draft it returns when none is
  stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

[1d] Move Save and Save As into the host

3 participants