diff --git a/AGENTS.md b/AGENTS.md
index 76021a292..fd1197177 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -79,7 +79,9 @@ The WebView root component is assigned to `globalThis.webViewComponent` (not exp
Drafts are loaded and saved through [AnalysisRepository](src/types/analysis-repository.ts), the seam a later storage backend implements. [DraftRepository](src/services/draftRepository.ts) implements it over the draft envelope and its per-book shards, and rewrites only the books that changed. It imports nothing from PAPI and reaches storage only through a `KeyValueStorage`. That lets the perf harness run it in Node, straight over the storage files ([perf/storage.ts](perf/storage.ts)).
-Saved projects are listed through [ProjectSummaries](src/services/projectSummaries.ts): one record holding every project's summary, rewritten on each project write so listing reads no analysis. It is PAPI-free in the same way.
+[The conformance suite](src/__tests__/analysis-repository-conformance.ts) defines the seam: every `AnalysisRepository` implementation must pass it, and [draftRepository.conformance.test.ts](src/__tests__/services/draftRepository.conformance.test.ts) runs it against `DraftRepository`. A behavior every implementation owes goes in the suite; one particular to an implementation, such as which shards a save rewrites, goes in that implementation's own tests. A later backend runs the suite by passing `describeAnalysisRepositoryConformance` a factory that returns a fresh `ConformanceStore` for each test.
+
+Saved projects are listed through [ProjectSummaries](src/services/projectSummaries.ts): one record holding every project's summary, rewritten on each project write so listing reads no analysis. Like `DraftRepository`, it is PAPI-free.
### Components
diff --git a/src/__tests__/analysis-repository-conformance.ts b/src/__tests__/analysis-repository-conformance.ts
new file mode 100644
index 000000000..d708b5ee3
--- /dev/null
+++ b/src/__tests__/analysis-repository-conformance.ts
@@ -0,0 +1,685 @@
+///
+
+import type { DraftProject, TextAnalysis } from 'interlinearizer';
+import type { AnalysisRepository, DraftExport } from '../types/analysis-repository';
+import { emptyAnalysis, emptyDraft } from '../types/empty-factories';
+import { CURRENT_MODEL_VERSION } from '../types/model-version';
+import { FIXTURE_STAMPS, makePhraseLink } from './test-helpers';
+
+/** One empty store an {@link AnalysisRepository} implementation keeps drafts in. */
+export interface ConformanceStore {
+ /**
+ * Opens a repository for `sourceProjectId`. Every repository this store opens reaches the same
+ * stored drafts, as one opened after a restart would.
+ */
+ open(sourceProjectId: string): Promise;
+
+ /** Stores a draft for `sourceProjectId` as a build with a newer model version would write it. */
+ storeNewerBuildDraft(sourceProjectId: string): Promise;
+
+ /**
+ * Leaves the stored analysis of one book of the draft for `sourceProjectId` unreadable, corrupt
+ * or lost outright, with the rest of the draft as it stands.
+ */
+ damageBook(sourceProjectId: string, bookCode: string, damage: 'corrupt' | 'lost'): Promise;
+
+ /**
+ * Leaves the stored draft for `sourceProjectId` well-formed but unusable, so no book in it can be
+ * vouched for.
+ */
+ damageDraft(sourceProjectId: string): Promise;
+
+ /**
+ * Makes the cleanup each save of the draft for `sourceProjectId` does after its content lands
+ * fail, leaving storage that {@link ConformanceStore.holdsLeftovers} reports, until the returned
+ * function is called.
+ */
+ failCleanup(sourceProjectId: string): () => void;
+
+ /** Whether storage holds anything for `sourceProjectId` that its stored draft no longer needs. */
+ holdsLeftovers(sourceProjectId: string): Promise;
+}
+
+const SOURCE = 'source-project';
+
+function tokenLink(id: string, analysisId: string, tokenRef: string, surfaceText: string) {
+ return {
+ id,
+ analysisId,
+ ...FIXTURE_STAMPS,
+ status: 'approved' as const,
+ token: { tokenRef, surfaceText },
+ };
+}
+
+/**
+ * Every record kind and every optional field, spread over GEN and EXO. One payload is linked from
+ * both books and one from neither. Arrays inside records are out of sorted order, so a repository
+ * that sorts them fails.
+ */
+function fullAnalysis(): TextAnalysis {
+ return {
+ segmentAnalyses: [
+ {
+ id: 'sa-exo',
+ ...FIXTURE_STAMPS,
+ surfaceText: 'And these',
+ confidence: 'high',
+ producer: 'human',
+ sourceUser: 'consultant',
+ freeTranslation: { en: 'And these are the names.' },
+ literalTranslation: { en: 'And these the names.' },
+ },
+ ],
+ segmentAnalysisLinks: [
+ {
+ id: 'sl-exo',
+ analysisId: 'sa-exo',
+ ...FIXTURE_STAMPS,
+ status: 'approved',
+ confidence: 'high',
+ segmentId: 'EXO 1:1',
+ },
+ ],
+ tokenAnalyses: [
+ { id: 'ta-and', ...FIXTURE_STAMPS, surfaceText: 'and', gloss: { en: 'and', fr: 'et' } },
+ {
+ id: 'ta-beginning',
+ ...FIXTURE_STAMPS,
+ surfaceText: 'beginning',
+ confidence: 'medium',
+ producer: 'pt9-import',
+ sourceUser: 'translator',
+ morphemes: [
+ {
+ id: 'm-2',
+ form: 'begin',
+ writingSystem: 'en',
+ entryRef: { authority: 'fwlite', projectId: 'lex', entryId: 'e-begin' },
+ senseRef: { authority: 'fwlite', senseId: 's-begin' },
+ allomorphRef: { authority: 'fwlite', allomorphId: 'a-begin' },
+ grammarRef: { authority: 'fwlite', msaId: 'msa-verb' },
+ gloss: { en: 'start' },
+ },
+ { id: 'm-1', form: '-ning', writingSystem: 'en' },
+ ],
+ morphemesStale: true,
+ pos: 'noun',
+ features: { case: 'nominative', number: 'singular' },
+ gloss: { en: 'start' },
+ glossSenseRef: { authority: 'fwlite', senseId: 's-start' },
+ },
+ { id: 'ta-unlinked', ...FIXTURE_STAMPS, surfaceText: 'spare', gloss: { en: 'spare' } },
+ ],
+ tokenAnalysisLinks: [
+ tokenLink('tl-gen-and', 'ta-and', 'GEN 1:1:0', 'and'),
+ tokenLink('tl-exo-and', 'ta-and', 'EXO 1:1:0', 'and'),
+ {
+ ...tokenLink('tl-gen-beginning', 'ta-beginning', 'GEN 1:1:9', 'beginning'),
+ status: 'suggested',
+ confidence: 'low',
+ },
+ ],
+ phraseAnalyses: [
+ {
+ id: 'pa-in-the',
+ ...FIXTURE_STAMPS,
+ surfaceText: 'in the',
+ confidence: 'guess',
+ producer: 'eflomal',
+ sourceUser: 'reviewer',
+ gloss: { en: 'in' },
+ senseRef: { authority: 'fwlite', senseId: 's-in' },
+ },
+ ],
+ phraseAnalysisLinks: [
+ {
+ ...makePhraseLink('pa-in-the', ['GEN 1:1:4', 'GEN 1:1:2'], ['the', 'in']),
+ confidence: 'guess',
+ },
+ ],
+ };
+}
+
+/** A draft setting every optional field, around {@link fullAnalysis}. */
+function fullDraft(): DraftProject {
+ return {
+ sourceProjectId: SOURCE,
+ modelVersion: CURRENT_MODEL_VERSION,
+ analysisLanguages: ['fr', 'en'],
+ targetProjectId: 'target-project',
+ suggestedName: 'Genesis draft',
+ suggestedDescription: 'A draft of Genesis and Exodus.',
+ analysis: fullAnalysis(),
+ dirty: true,
+ segmentation: {
+ removedVerseStarts: ['GEN 1:2'],
+ addedStarts: [{ tokenRef: 'GEN 1:1:4', surfaceText: 'the' }],
+ },
+ };
+}
+
+/**
+ * {@link fullDraft} cut to EXO's records, with the draft's optional fields dropped and its other
+ * metadata changed, so a save that keeps any of the stored draft shows.
+ */
+function exodusOnlyDraft(): DraftProject {
+ const { analysis } = fullDraft();
+ return {
+ sourceProjectId: SOURCE,
+ modelVersion: CURRENT_MODEL_VERSION,
+ analysisLanguages: ['en'],
+ analysis: {
+ ...emptyAnalysis(),
+ segmentAnalyses: analysis.segmentAnalyses,
+ segmentAnalysisLinks: analysis.segmentAnalysisLinks,
+ tokenAnalyses: analysis.tokenAnalyses.filter(({ id }) => id === 'ta-and'),
+ tokenAnalysisLinks: analysis.tokenAnalysisLinks.filter(({ id }) => id === 'tl-exo-and'),
+ },
+ dirty: false,
+ };
+}
+
+/** {@link fullDraft} without GEN's records, as it loads once GEN's stored analysis is unreadable. */
+function fullDraftWithoutGenesis(): DraftProject {
+ const draft = fullDraft();
+ const { analysis } = draft;
+ return {
+ ...draft,
+ analysis: {
+ ...analysis,
+ tokenAnalyses: analysis.tokenAnalyses.filter(({ id }) => id !== 'ta-beginning'),
+ tokenAnalysisLinks: analysis.tokenAnalysisLinks.filter(({ id }) => id === 'tl-exo-and'),
+ phraseAnalyses: [],
+ phraseAnalysisLinks: [],
+ },
+ };
+}
+
+/**
+ * The draft with each record list sorted by id. Lists of records promise no order, but the arrays
+ * inside a record keep theirs.
+ */
+function inIdOrder(draft: DraftProject): DraftProject {
+ const byId = (records: readonly T[]): T[] =>
+ [...records].sort((a, b) => a.id.localeCompare(b.id));
+ const { analysis } = draft;
+ return {
+ ...draft,
+ analysis: {
+ ...analysis,
+ segmentAnalyses: byId(analysis.segmentAnalyses),
+ segmentAnalysisLinks: byId(analysis.segmentAnalysisLinks),
+ tokenAnalyses: byId(analysis.tokenAnalyses),
+ tokenAnalysisLinks: byId(analysis.tokenAnalysisLinks),
+ phraseAnalyses: byId(analysis.phraseAnalyses),
+ phraseAnalysisLinks: byId(analysis.phraseAnalysisLinks),
+ },
+ };
+}
+
+/**
+ * Changes `draft` in place at each level of nesting, so a repository still holding part of it
+ * shows.
+ */
+function changeInPlace(draft: DraftProject): void {
+ const { analysis, segmentation } = draft;
+ draft.analysisLanguages.push('de');
+ [
+ ...analysis.segmentAnalyses,
+ ...analysis.segmentAnalysisLinks,
+ ...analysis.tokenAnalyses,
+ ...analysis.tokenAnalysisLinks,
+ ...analysis.phraseAnalyses,
+ ...analysis.phraseAnalysisLinks,
+ ].forEach((record) => {
+ record.updatedAt = '2026-02-02T00:00:00.000Z';
+ });
+ analysis.segmentAnalyses.forEach((payload) => {
+ if (payload.freeTranslation) payload.freeTranslation.en = 'changed';
+ });
+ analysis.tokenAnalyses.forEach((payload) => {
+ if (payload.gloss) payload.gloss.en = 'changed';
+ payload.morphemes?.forEach((morpheme) => {
+ if (morpheme.gloss) morpheme.gloss.en = 'changed';
+ });
+ });
+ analysis.phraseAnalyses.forEach((payload) => {
+ if (payload.gloss) payload.gloss.en = 'changed';
+ });
+ analysis.tokenAnalysisLinks.forEach((link) => {
+ link.token.surfaceText = 'changed';
+ });
+ analysis.phraseAnalysisLinks.forEach((link) => {
+ link.tokens.forEach((token) => {
+ token.surfaceText = 'changed';
+ });
+ });
+ segmentation?.removedVerseStarts.push('EXO 1:2');
+ segmentation?.addedStarts.forEach((start) => {
+ start.surfaceText = 'changed';
+ });
+}
+
+/** Expects `value` to survive a JSON round trip unchanged and to hold no `null`. */
+function expectJsonPlain(value: unknown): void {
+ expect(JSON.parse(JSON.stringify(value))).toStrictEqual(value);
+ expect(JSON.stringify(value)).not.toMatch(/\bnull\b/);
+}
+
+/**
+ * Expects `exported` to name no unreadable book, and returns its draft with record lists in id
+ * order.
+ */
+function readableDraft(exported: DraftExport): DraftProject {
+ expect(exported.unreadableBooks).toStrictEqual([]);
+ return inIdOrder(exported.draft);
+}
+
+/**
+ * Loads the draft through `repository`, expecting every book readable, with record lists in id
+ * order.
+ */
+async function load(repository: AnalysisRepository): Promise {
+ return readableDraft(await repository.exportAll());
+}
+
+/**
+ * Loads the draft through a newly opened repository, expecting every book readable, with record
+ * lists in id order.
+ */
+async function reload(store: ConformanceStore, sourceProjectId = SOURCE): Promise {
+ return load(await store.open(sourceProjectId));
+}
+
+/** Loads the draft for copying through a newly opened repository, with record lists in id order. */
+async function reloadForCopy(store: ConformanceStore): Promise {
+ const { draft, unreadableBooks } = await (await store.open(SOURCE)).exportForCopy();
+ return { draft: inIdOrder(draft), unreadableBooks };
+}
+
+/** A repository that has loaded {@link fullDraft} with GEN's stored analysis unreadable. */
+async function loadedWithUnreadableGenesis(store: ConformanceStore): Promise {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await store.damageBook(SOURCE, 'GEN', 'corrupt');
+ const repository = await store.open(SOURCE);
+ await repository.exportAll();
+ return repository;
+}
+
+/**
+ * Declares the tests every {@link AnalysisRepository} implementation must pass, titled with the
+ * implementation's name. Each test runs against a fresh store from `createStore`.
+ */
+export function describeAnalysisRepositoryConformance(
+ implementationName: string,
+ createStore: () => ConformanceStore,
+): void {
+ describe(`${implementationName} conforms to AnalysisRepository`, () => {
+ let store: ConformanceStore;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ describe('exportAll', () => {
+ it('loads an empty draft for its source project when none is saved', async () => {
+ await expect(reload(store)).resolves.toStrictEqual(emptyDraft(SOURCE));
+ });
+
+ it('returns a saved draft as JSON-plain values, omitting absent optional fields rather than nulling them', async () => {
+ const minimal: DraftProject = {
+ ...emptyDraft(SOURCE),
+ analysis: {
+ ...emptyAnalysis(),
+ tokenAnalyses: [{ id: 'ta-and', ...FIXTURE_STAMPS, surfaceText: 'and' }],
+ tokenAnalysisLinks: [tokenLink('tl-and', 'ta-and', 'GEN 1:1:0', 'and')],
+ },
+ };
+ await (await store.open(SOURCE)).replaceAll(minimal);
+
+ expectJsonPlain(await (await store.open(SOURCE)).exportAll());
+ await expect(reload(store)).resolves.toStrictEqual(minimal);
+ });
+
+ it('returns a never-saved draft as JSON-plain values', async () => {
+ expectJsonPlain(await (await store.open(SOURCE)).exportAll());
+ });
+
+ it('returns a draft the caller may change without changing what is stored', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ changeInPlace((await repository.exportAll()).draft);
+
+ await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('refuses a draft written by a newer build', async () => {
+ await store.storeNewerBuildDraft(SOURCE);
+
+ await expect((await store.open(SOURCE)).exportAll()).rejects.toThrow();
+ });
+
+ it.each(['corrupt', 'lost'] as const)(
+ 'names a book whose stored analysis is %s, and loads the rest',
+ async (damage) => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await store.damageBook(SOURCE, 'GEN', damage);
+
+ const { draft, unreadableBooks } = await (await store.open(SOURCE)).exportAll();
+
+ expect(unreadableBooks).toStrictEqual(['GEN']);
+ expect(inIdOrder(draft)).toStrictEqual(inIdOrder(fullDraftWithoutGenesis()));
+ },
+ );
+
+ it('loads an unusable draft as an empty one', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await store.damageDraft(SOURCE);
+
+ await expect(reload(store)).resolves.toStrictEqual(emptyDraft(SOURCE));
+ });
+ });
+
+ describe('exportForCopy', () => {
+ it('exports a saved draft whole, naming no book', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: inIdOrder(fullDraft()),
+ unreadableBooks: [],
+ });
+ });
+
+ it('exports an empty draft for its source project when none is saved', async () => {
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: emptyDraft(SOURCE),
+ unreadableBooks: [],
+ });
+ });
+
+ it.each(['corrupt', 'lost'] as const)(
+ 'names a book whose stored analysis is %s, and exports the rest',
+ async (damage) => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await store.damageBook(SOURCE, 'GEN', damage);
+
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: inIdOrder(fullDraftWithoutGenesis()),
+ unreadableBooks: ['GEN'],
+ });
+ },
+ );
+
+ it('refuses an unusable draft rather than exporting it empty', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await store.damageDraft(SOURCE);
+
+ await expect((await store.open(SOURCE)).exportForCopy()).rejects.toThrow();
+ });
+
+ it('refuses a draft written by a newer build', async () => {
+ await store.storeNewerBuildDraft(SOURCE);
+
+ await expect((await store.open(SOURCE)).exportForCopy()).rejects.toThrow();
+ });
+ });
+
+ describe('replaceAll', () => {
+ it('saves every record kind and optional field for the same repository to load', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('saves a draft for a newly opened repository to load', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('keeps a payload linked from several books as one record', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ const { analysis } = await reload(store);
+
+ expect(analysis.tokenAnalyses.filter(({ id }) => id === 'ta-and')).toHaveLength(1);
+ });
+
+ it('saves an edit to a payload linked from several books', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ const { draft } = await repository.exportAll();
+ draft.analysis.tokenAnalyses = draft.analysis.tokenAnalyses.map((payload) =>
+ payload.id === 'ta-and' ? { ...payload, gloss: { en: 'and then' } } : payload,
+ );
+
+ await repository.replaceAll(draft);
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(draft));
+ });
+
+ it('stamps what it saves with the current model version', async () => {
+ await (
+ await store.open(SOURCE)
+ ).replaceAll({ ...fullDraft(), modelVersion: CURRENT_MODEL_VERSION - 1 });
+
+ const { modelVersion } = await reload(store);
+
+ expect(modelVersion).toBe(CURRENT_MODEL_VERSION);
+ });
+
+ it('replaces the stored draft whole, dropping records the saved draft lacks', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ await repository.exportAll();
+
+ await repository.replaceAll(exodusOnlyDraft());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft()));
+ });
+
+ it('drops records the saved draft lacks even when this repository never loaded them', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft()));
+ });
+
+ it('saves in full after reloading a draft another repository replaced', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft());
+ await expect(load(repository)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft()));
+
+ await repository.replaceAll(fullDraft());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('saves an emptied analysis as empty', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+
+ await repository.replaceAll({ ...fullDraft(), analysis: emptyAnalysis() });
+
+ await expect(reload(store)).resolves.toStrictEqual({
+ ...fullDraft(),
+ analysis: emptyAnalysis(),
+ });
+ });
+
+ it('restores records a later save brings back', async () => {
+ const repository = await store.open(SOURCE);
+ await repository.replaceAll(fullDraft());
+ await repository.replaceAll(exodusOnlyDraft());
+
+ await repository.replaceAll(fullDraft());
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('stores what it was given, unaffected by later changes to that draft', async () => {
+ const repository = await store.open(SOURCE);
+ const draft = fullDraft();
+ await repository.replaceAll(draft);
+
+ changeInPlace(draft);
+
+ await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('stores in-place changes to a saved draft when that draft is saved again', async () => {
+ const repository = await store.open(SOURCE);
+ const draft = fullDraft();
+ await repository.replaceAll(draft);
+ changeInPlace(draft);
+
+ await repository.replaceAll(draft);
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(draft));
+ });
+
+ it('keeps the drafts of different source projects apart', async () => {
+ const other = { ...exodusOnlyDraft(), sourceProjectId: 'other-project' };
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ await (await store.open('other-project')).replaceAll(other);
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ await expect(reload(store, 'other-project')).resolves.toStrictEqual(inIdOrder(other));
+ });
+
+ it('leaves a draft written by a newer build in place, refusing to overwrite it', async () => {
+ await store.storeNewerBuildDraft(SOURCE);
+
+ await expect((await store.open(SOURCE)).replaceAll(fullDraft())).rejects.toThrow();
+
+ await expect((await store.open(SOURCE)).exportAll()).rejects.toThrow();
+ });
+
+ it('resolves once the draft has landed even if cleanup after it fails, and a later save finishes that cleanup', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ const restore = store.failCleanup(SOURCE);
+
+ await expect(
+ (await store.open(SOURCE)).replaceAll(exodusOnlyDraft()),
+ ).resolves.toStrictEqual([]);
+ await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(true);
+
+ restore();
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft()));
+ await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft());
+ await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(false);
+ });
+ });
+
+ describe('replaceAll after loading a book that could not be read', () => {
+ it('keeps the book as stored when the draft carries nothing for it, leaving out no book', async () => {
+ const repository = await loadedWithUnreadableGenesis(store);
+
+ await expect(repository.replaceAll(exodusOnlyDraft())).resolves.toStrictEqual([]);
+
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: inIdOrder(exodusOnlyDraft()),
+ unreadableBooks: ['GEN'],
+ });
+ });
+
+ it('resolves with the book, keeping it as stored, when the draft carries analysis for it', async () => {
+ const repository = await loadedWithUnreadableGenesis(store);
+ const withoutSegments = (draft: DraftProject): DraftProject => ({
+ ...draft,
+ analysis: { ...draft.analysis, segmentAnalyses: [], segmentAnalysisLinks: [] },
+ });
+
+ await expect(repository.replaceAll(withoutSegments(fullDraft()))).resolves.toStrictEqual([
+ 'GEN',
+ ]);
+
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: inIdOrder(withoutSegments(fullDraftWithoutGenesis())),
+ unreadableBooks: ['GEN'],
+ });
+ });
+
+ it('writes the book like any other once told to discard what could not be read', async () => {
+ const repository = await loadedWithUnreadableGenesis(store);
+
+ await expect(
+ repository.replaceAll(fullDraft(), { discardUnreadable: true }),
+ ).resolves.toStrictEqual([]);
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft()));
+ });
+
+ it('wipes the book the draft carries nothing for once told to discard what could not be read', async () => {
+ const repository = await loadedWithUnreadableGenesis(store);
+
+ await repository.replaceAll(exodusOnlyDraft(), { discardUnreadable: true });
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft()));
+ await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(false);
+ });
+ });
+
+ describe('markClean', () => {
+ it('records the stored draft as clean, leaving its content as it stands', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+
+ await (await store.open(SOURCE)).markClean();
+
+ await expect(reload(store)).resolves.toStrictEqual(
+ inIdOrder({ ...fullDraft(), dirty: false }),
+ );
+ });
+
+ it('keeps a book that could not be read as stored', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await store.damageBook(SOURCE, 'GEN', 'corrupt');
+
+ await (await store.open(SOURCE)).markClean();
+
+ await expect(reloadForCopy(store)).resolves.toStrictEqual({
+ draft: inIdOrder({ ...fullDraftWithoutGenesis(), dirty: false }),
+ unreadableBooks: ['GEN'],
+ });
+ });
+
+ it('leaves an already-clean draft as it stands', async () => {
+ await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft());
+
+ await (await store.open(SOURCE)).markClean();
+
+ await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft()));
+ });
+
+ it('resolves when no draft is saved, which still loads as empty', async () => {
+ await (await store.open(SOURCE)).markClean();
+
+ await expect(reload(store)).resolves.toStrictEqual(emptyDraft(SOURCE));
+ });
+
+ it('refuses a draft written by a newer build, leaving it in place', async () => {
+ await store.storeNewerBuildDraft(SOURCE);
+
+ await expect((await store.open(SOURCE)).markClean()).rejects.toThrow();
+
+ await expect((await store.open(SOURCE)).exportAll()).rejects.toThrow();
+ });
+
+ it('refuses an unusable draft, leaving it in place', async () => {
+ await (await store.open(SOURCE)).replaceAll(fullDraft());
+ await store.damageDraft(SOURCE);
+
+ await expect((await store.open(SOURCE)).markClean()).rejects.toThrow();
+
+ await expect((await store.open(SOURCE)).exportForCopy()).rejects.toThrow();
+ });
+ });
+ });
+}
diff --git a/src/__tests__/services/draftRepository.conformance.test.ts b/src/__tests__/services/draftRepository.conformance.test.ts
new file mode 100644
index 000000000..7a707768f
--- /dev/null
+++ b/src/__tests__/services/draftRepository.conformance.test.ts
@@ -0,0 +1,84 @@
+///
+
+import { DraftRepository } from '../../services/draftRepository';
+import { draftAnalysisKey, draftKey } from '../../services/storageKeys';
+import { emptyDraft } from '../../types/empty-factories';
+import { CURRENT_MODEL_VERSION } from '../../types/model-version';
+import { describeAnalysisRepositoryConformance } from '../analysis-repository-conformance';
+import { memoryStorage, silentLogger } from '../test-helpers';
+
+const logger = silentLogger();
+
+/** Whether the current test damaged storage or failed its cleanup, which a repository may log. */
+let warningsExpected = false;
+
+describeAnalysisRepositoryConformance('DraftRepository', () => {
+ const { storage: memory, records } = memoryStorage();
+ warningsExpected = false;
+ /** The key of the draft whose cleanup fails; unset while cleanup succeeds. */
+ let failingDraftKey: string | undefined;
+ let landed = false;
+ /**
+ * Refuses writes and deletions of the failing draft's other records from the time a save of it
+ * lands until the draft is next read, which is how every call on it starts.
+ */
+ const refuseAfterLanding = async (key: string, operation: () => Promise) => {
+ if (landed && key.startsWith(`${failingDraftKey}:`)) throw new Error('cleanup refused');
+ await operation();
+ if (key === failingDraftKey) landed = true;
+ };
+ const storage = {
+ read: async (key: string) => {
+ if (key === failingDraftKey) landed = false;
+ return memory.read(key);
+ },
+ write: (key: string, data: string) => refuseAfterLanding(key, () => memory.write(key, data)),
+ delete: (key: string) => refuseAfterLanding(key, () => memory.delete(key)),
+ };
+ return {
+ open: async (sourceProjectId) => new DraftRepository(storage, sourceProjectId, logger),
+ storeNewerBuildDraft: (sourceProjectId) =>
+ memory.write(
+ draftKey(sourceProjectId),
+ JSON.stringify({ ...emptyDraft(sourceProjectId), modelVersion: CURRENT_MODEL_VERSION + 1 }),
+ ),
+ damageBook: async (sourceProjectId, bookCode, damage) => {
+ warningsExpected = true;
+ const key = draftAnalysisKey(sourceProjectId, bookCode);
+ if (damage === 'lost') records.delete(key);
+ else records.set(key, 'not json');
+ },
+ damageDraft: async (sourceProjectId) => {
+ warningsExpected = true;
+ records.set(draftKey(sourceProjectId), JSON.stringify({ dirty: true }));
+ },
+ failCleanup: (sourceProjectId) => {
+ warningsExpected = true;
+ failingDraftKey = draftKey(sourceProjectId);
+ return () => {
+ failingDraftKey = undefined;
+ landed = false;
+ };
+ },
+ holdsLeftovers: async (sourceProjectId) => {
+ const stored: { analysisBooks?: string[] } = JSON.parse(
+ records.get(draftKey(sourceProjectId)) ?? '{}',
+ );
+ const needed = new Set([
+ draftKey(sourceProjectId),
+ ...(stored.analysisBooks ?? []).map((bookCode) =>
+ draftAnalysisKey(sourceProjectId, bookCode),
+ ),
+ ]);
+ return [...records.keys()].some(
+ (key) => key.startsWith(`${draftKey(sourceProjectId)}:`) && !needed.has(key),
+ );
+ },
+ };
+});
+
+// A conforming save and load of well-formed drafts has nothing to warn about, so a warning there
+// means the repository found fault with records it wrote itself.
+afterEach(() => {
+ if (!warningsExpected) expect(logger.warn).not.toHaveBeenCalled();
+});
diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts
index 9915fd9d6..bf889a1b7 100644
--- a/src/__tests__/services/draftRepository.test.ts
+++ b/src/__tests__/services/draftRepository.test.ts
@@ -3,31 +3,9 @@
import type { DraftProject, TextAnalysis } from 'interlinearizer';
import { DraftRepository } from '../../services/draftRepository';
import { draftAnalysisKey, draftKey } from '../../services/storageKeys';
-import type { KeyValueStorage, WarningLogger } from '../../types/analysis-repository';
import { emptyAnalysis, emptyDraft } from '../../types/empty-factories';
import { CURRENT_MODEL_VERSION } from '../../types/model-version';
-import { FIXTURE_STAMPS, makePhraseLink } from '../test-helpers';
-
-/** Storage held in a map, recording every write in order. */
-function memoryStorage(initial: Record = {}) {
- const records = new Map(Object.entries(initial));
- const writes: string[] = [];
- const storage: KeyValueStorage = {
- read: async (key) => records.get(key),
- write: async (key, data) => {
- writes.push(key);
- records.set(key, data);
- },
- delete: async (key) => {
- records.delete(key);
- },
- };
- return { storage, records, writes };
-}
-
-function silentLogger(): WarningLogger & { warn: jest.Mock } {
- return { warn: jest.fn() };
-}
+import { FIXTURE_STAMPS, makePhraseLink, memoryStorage, silentLogger } from '../test-helpers';
/** An analysis with a token payload shared by GEN and EXO, a phrase in GEN and a segment in EXO. */
function twoBookAnalysis(): TextAnalysis {
@@ -63,25 +41,6 @@ function twoBookDraft(): DraftProject {
}
describe('DraftRepository', () => {
- it('loads an empty draft for its source project when none is stored', async () => {
- const { storage } = memoryStorage();
-
- await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual({
- draft: emptyDraft('src'),
- unreadableBooks: [],
- });
- });
-
- it('loads in a new instance what another saved', async () => {
- const { storage } = memoryStorage();
- await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft());
-
- await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual({
- draft: twoBookDraft(),
- unreadableBooks: [],
- });
- });
-
it('loads a payload shared by several books once', async () => {
const { storage, records } = memoryStorage();
await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft());
@@ -142,68 +101,7 @@ describe('DraftRepository', () => {
);
});
- it('names a book whose shard is unusable, and loads the rest', async () => {
- const { storage, records } = memoryStorage();
- await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft());
- records.set(draftAnalysisKey('src', 'GEN'), 'not json');
-
- const { draft, unreadableBooks } = await new DraftRepository(
- storage,
- 'src',
- silentLogger(),
- ).exportAll();
-
- expect(unreadableBooks).toEqual(['GEN']);
- expect(draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']);
- });
-
describe('exportForCopy', () => {
- it('exports a readable draft whole, naming no book', async () => {
- const { storage } = memoryStorage();
- await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft());
-
- await expect(
- new DraftRepository(storage, 'src', silentLogger()).exportForCopy(),
- ).resolves.toEqual({ draft: twoBookDraft(), unreadableBooks: [] });
- });
-
- it('names a book whose shard is unusable, and exports the rest', async () => {
- const { storage, records } = memoryStorage();
- await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft());
- records.set(draftAnalysisKey('src', 'GEN'), 'not json');
-
- const { draft, unreadableBooks } = await new DraftRepository(
- storage,
- 'src',
- silentLogger(),
- ).exportForCopy();
-
- expect(unreadableBooks).toEqual(['GEN']);
- expect(draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']);
- });
-
- it('names a book whose shard the manifest lists but storage lacks', async () => {
- const { storage, records } = memoryStorage();
- await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft());
- records.delete(draftAnalysisKey('src', 'EXO'));
-
- const { unreadableBooks } = await new DraftRepository(
- storage,
- 'src',
- silentLogger(),
- ).exportForCopy();
-
- expect(unreadableBooks).toEqual(['EXO']);
- });
-
- it('exports a fresh empty draft when none is stored', async () => {
- const { storage } = memoryStorage();
-
- await expect(
- new DraftRepository(storage, 'src', silentLogger()).exportForCopy(),
- ).resolves.toEqual({ draft: emptyDraft('src'), unreadableBooks: [] });
- });
-
it('exports an inline draft whole, naming no book', async () => {
const inline = twoBookDraft();
const { storage } = memoryStorage({ [draftKey('src')]: JSON.stringify(inline) });
@@ -320,14 +218,6 @@ describe('DraftRepository', () => {
expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json');
});
- it('leaves out no book when the draft carries nothing for an unreadable one', async () => {
- const { repository, draft } = await loadedWithUnreadableGen();
-
- await expect(repository.replaceAll({ ...draft, analysis: emptyAnalysis() })).resolves.toEqual(
- [],
- );
- });
-
it('leaves out the analysis a draft carries for an unreadable book, keeping its shard', async () => {
const { repository, records } = await loadedWithUnreadableGen();
@@ -336,29 +226,6 @@ describe('DraftRepository', () => {
expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json');
});
- it('saves the rest of a draft carrying analysis for an unreadable book', async () => {
- const { repository, storage } = await loadedWithUnreadableGen();
- const draft = twoBookDraft();
-
- await repository.replaceAll({
- ...draft,
- analysis: { ...draft.analysis, segmentAnalyses: [], segmentAnalysisLinks: [] },
- });
-
- const reloaded = await new DraftRepository(storage, 'src', silentLogger()).exportForCopy();
- expect(reloaded.unreadableBooks).toEqual(['GEN']);
- expect(reloaded.draft.analysis.segmentAnalysisLinks).toEqual([]);
- expect(reloaded.draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']);
- });
-
- it('writes over an unreadable book once the draft is started over', async () => {
- const { repository, records } = await loadedWithUnreadableGen();
-
- await repository.replaceAll(twoBookDraft(), { discardUnreadable: true });
-
- expect(records.get(draftAnalysisKey('src', 'GEN'))).not.toBe('not json');
- });
-
it('wipes an unreadable book the draft carries nothing for once the draft is started over', async () => {
const { repository, draft, records } = await loadedWithUnreadableGen();
diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts
index 6a003cd86..745cb1093 100644
--- a/src/__tests__/test-helpers.ts
+++ b/src/__tests__/test-helpers.ts
@@ -11,9 +11,10 @@ import {
} from '../components/PhraseStripContext';
import type { SegmentPages } from '../hooks/useSegmentPages';
import { summarizeProject } from '../services/projectSummaries';
+import type { KeyValueStorage, WarningLogger } from '../types/analysis-repository';
import { emptyAnalysis } from '../types/empty-factories';
-import { CURRENT_MODEL_VERSION } from '../types/model-version';
import type { InterlinearProjectSummary } from '../types/interlinear-project-summary';
+import { CURRENT_MODEL_VERSION } from '../types/model-version';
import type { OutlineSegment } from '../types/segment-pages';
/** Minimal execution token-shaped object for tests (structural match for ExecutionToken). */
@@ -546,3 +547,30 @@ export function getMockedSendCommand(papiModule: unknown): jest.Mock {
export function getMockedWaitForNetworkObject(papiModule: unknown): jest.Mock {
return getMockedPapiFn(papiModule, ['networkObjectStatus', 'waitForNetworkObject']);
}
+
+/**
+ * Storage held in a map seeded from `initial`.
+ *
+ * @returns The storage, the map backing it, and the keys written so far, in write order, with
+ * repeats.
+ */
+export function memoryStorage(initial: Record = {}) {
+ const records = new Map(Object.entries(initial));
+ const writes: string[] = [];
+ const storage: KeyValueStorage = {
+ read: async (key) => records.get(key),
+ write: async (key, data) => {
+ writes.push(key);
+ records.set(key, data);
+ },
+ delete: async (key) => {
+ records.delete(key);
+ },
+ };
+ return { storage, records, writes };
+}
+
+/** A logger whose warnings a test can inspect instead of printing. */
+export function silentLogger(): WarningLogger & { warn: jest.Mock } {
+ return { warn: jest.fn() };
+}
diff --git a/src/__tests__/utils/analysis-violations.test.ts b/src/__tests__/utils/analysis-violations.test.ts
index ca63095c9..b960fa11c 100644
--- a/src/__tests__/utils/analysis-violations.test.ts
+++ b/src/__tests__/utils/analysis-violations.test.ts
@@ -1,14 +1,9 @@
///
import type { TextAnalysis, TokenAnalysisLink } from 'interlinearizer';
-import type { WarningLogger } from '../../types/analysis-repository';
import { emptyAnalysis } from '../../types/empty-factories';
import { reportAnalysisViolations } from '../../utils/analysis-violations';
-import { FIXTURE_STAMPS } from '../test-helpers';
-
-function silentLogger(): WarningLogger & { warn: jest.Mock } {
- return { warn: jest.fn() };
-}
+import { FIXTURE_STAMPS, silentLogger } from '../test-helpers';
function linkTo(analysisId: string, tokenRef: string, id = `tl-${tokenRef}`): TokenAnalysisLink {
return {
diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts
index c345bb68c..ea40cca45 100644
--- a/src/types/analysis-repository.ts
+++ b/src/types/analysis-repository.ts
@@ -53,11 +53,17 @@ export type DraftExport = {
/**
* Reads and writes one source project's draft. Every call is async and takes and returns only
* JSON-plain values, so an implementation may sit behind a network boundary or another language.
+ * Once a call settles, the caller may change a draft it passed in or got back without changing what
+ * is stored. Lists of records come back in no promised order.
+ *
+ * Calls for one draft must not overlap, whether on one repository or several. After one repository
+ * saves a draft, any other that had already loaded or saved it must load it again before its next
+ * save.
*/
export interface AnalysisRepository {
/**
- * Loads the whole stored draft. Stored content that cannot be read loads as empty, so one damaged
- * record never hides the rest.
+ * Loads the whole stored draft, or an empty one for the source project when none is stored.
+ * Stored content that cannot be read loads as empty, so one damaged record never hides the rest.
*
* @throws {SyntaxError} If the draft's stored value contains invalid JSON.
* @throws {Error} If the stored draft was written by a newer build.
@@ -66,7 +72,8 @@ export interface AnalysisRepository {
exportAll(): Promise;
/**
- * Loads the whole stored draft for copying into a project.
+ * Loads the whole stored draft for copying into a project, or an empty one for the source project
+ * when none is stored.
*
* @throws {SyntaxError} If the draft's stored value contains invalid JSON.
* @throws {Error} If the stored draft was written by a newer build.
@@ -76,10 +83,13 @@ export interface AnalysisRepository {
exportForCopy(): Promise;
/**
- * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when
- * cleanup after it fails. A book whose stored analysis could not be read is kept as stored unless
- * `options` discards it: a draft that loaded it empty never wipes it, and analysis the draft
- * carries for it never stands in for what could not be read.
+ * Saves the whole draft, replacing the stored one, stamped with this build's model version. Once
+ * the save's cleanup succeeds, nothing the saved draft no longer needs stays stored, apart from
+ * the stored analysis a save keeps for unreadable books. Content that lands counts as saved even
+ * when that cleanup fails; a later save, from any repository, finishes the cleanup. A book whose
+ * stored analysis could not be read is kept as stored unless `options` discards it: a draft that
+ * loaded it empty never wipes it, and analysis the draft carries for it never stands in for what
+ * could not be read.
*
* @returns The books whose analysis in the draft was left out for that reason; empty when the
* draft was saved whole.