Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
950b844
Centralize all backend environment access into a single typed, valida…
aaronstevenwhite Jun 17, 2026
3cc1193
Merge pull request #145 from parafovea/refactor/phase-1a-backend-config
aaronstevenwhite Jun 17, 2026
360f1e8
Add a create-release job to release.yml that extracts the matching CH…
aaronstevenwhite Jun 17, 2026
fac87bb
Merge pull request #146 from parafovea/fix/release-workflow-auto-gith…
aaronstevenwhite Jun 17, 2026
d0c633b
Centralize all frontend environment access into a single typed, deep-…
aaronstevenwhite Jun 17, 2026
3bc662d
Merge pull request #147 from parafovea/refactor/phase-1b-frontend-config
aaronstevenwhite Jun 17, 2026
10038a1
Promote the model-service to a single typed pydantic-settings Setting…
aaronstevenwhite Jun 17, 2026
df2c545
Merge pull request #148 from parafovea/refactor/phase-1c-model-servic…
aaronstevenwhite Jun 17, 2026
075576c
Add a CI configuration-drift guard (scripts/check-env-example.mjs, wi…
aaronstevenwhite Jun 17, 2026
fbb6cd3
Merge pull request #149 from parafovea/refactor/phase-1d-config-drift…
aaronstevenwhite Jun 17, 2026
96a7454
Pin Node and pnpm in one place by adding a root .nvmrc (Node 22) and …
aaronstevenwhite Jun 17, 2026
7b27938
Merge pull request #150 from parafovea/refactor/phase-2a-node-pnpm-pin
aaronstevenwhite Jun 17, 2026
206314a
Add a verify-compose CI job that validates all ten docker compose con…
aaronstevenwhite Jun 17, 2026
909a288
Merge pull request #151 from parafovea/refactor/phase-2b-compose-vali…
aaronstevenwhite Jun 17, 2026
eb03c3a
Delete the duplicate server/docker-compose.dev.yml that redefined Pos…
aaronstevenwhite Jun 17, 2026
e6b19c8
Merge pull request #152 from parafovea/refactor/phase-2c-dev-infra-co…
aaronstevenwhite Jun 17, 2026
9b2541c
Consolidate the model-service's orphaned tests directory into the con…
aaronstevenwhite Jun 17, 2026
252963a
Merge pull request #153 from parafovea/refactor/phase-3a-consolidate-…
aaronstevenwhite Jun 17, 2026
7453b6e
Add a root Makefile as the single install/lint/typecheck/test/build e…
aaronstevenwhite Jun 17, 2026
1a11923
Merge pull request #154 from parafovea/refactor/phase-3b-unified-test…
aaronstevenwhite Jun 17, 2026
2994852
Reconcile CI documentation with reality by rewriting the stale workfl…
aaronstevenwhite Jun 17, 2026
dcf10da
Merge pull request #155 from parafovea/refactor/phase-3c-ci-reconcili…
aaronstevenwhite Jun 17, 2026
c7cfc3b
Stop the startup config validation from requiring API_KEY_ENCRYPTION_…
aaronstevenwhite Jun 18, 2026
3f194e0
Merge pull request #156 from parafovea/fix/api-key-encryption-startup…
aaronstevenwhite Jun 18, 2026
b09a655
Extract the personas domain into a PersonaRepository (pure data acces…
aaronstevenwhite Jun 18, 2026
25dddcc
Make the persistent test persona name unique per test (workerIndex pl…
aaronstevenwhite Jun 18, 2026
9f14854
Merge pull request #157 from parafovea/refactor/phase-4-1-personas-se…
aaronstevenwhite Jun 18, 2026
ca12444
Extract the projects domain into a ProjectRepository and ProjectServi…
aaronstevenwhite Jun 18, 2026
bb90695
Merge pull request #158 from parafovea/refactor/phase-4-2-projects-se…
aaronstevenwhite Jun 18, 2026
6950e35
Extract the claims domain into a ClaimRepository and ClaimService, re…
aaronstevenwhite Jun 18, 2026
19caa51
Merge pull request #159 from parafovea/refactor/phase-4-3-claims-serv…
aaronstevenwhite Jun 18, 2026
e77e2f9
Extract the world-state domain into a WorldStateRepository and WorldS…
aaronstevenwhite Jun 18, 2026
646e6d8
Merge pull request #160 from parafovea/refactor/phase-4-4-world-servi…
aaronstevenwhite Jun 18, 2026
2e1bee8
Split the 2232-line import-handler.ts import state machine into focus…
aaronstevenwhite Jun 18, 2026
aaf2a87
Merge pull request #161 from parafovea/refactor/phase-4-5-import-hand…
aaronstevenwhite Jun 18, 2026
293795c
Stand up the OpenAPI to frontend-types generation pipeline by emittin…
aaronstevenwhite Jun 18, 2026
ccac9ec
Merge pull request #162 from parafovea/refactor/phase-4-6a-openapi-fr…
aaronstevenwhite Jun 18, 2026
5e3750b
Decouple the model-service contract layer from the ML stack by removi…
aaronstevenwhite Jun 19, 2026
1d9832d
Merge pull request #163 from parafovea/refactor/phase-4-6b-model-serv…
aaronstevenwhite Jun 19, 2026
4ae665e
Add the server↔model-service contract pipeline by emitting a committe…
aaronstevenwhite Jun 19, 2026
bb3d97d
Merge pull request #164 from parafovea/refactor/phase-4-6b2-model-ser…
aaronstevenwhite Jun 19, 2026
8c76b87
Resolve cross-user object names server-side by adding an optional lin…
aaronstevenwhite Jun 19, 2026
e060f7b
Merge pull request #165 from parafovea/refactor/phase-4-6c-linked-obj…
aaronstevenwhite Jun 19, 2026
2ac946d
Make POST /api/annotations idempotent on an optional client-supplied …
aaronstevenwhite Jun 19, 2026
28cdd11
Merge pull request #166 from parafovea/refactor/phase-4-7-annotation-…
aaronstevenwhite Jun 19, 2026
7db24c2
Fix the annotation autosave idle loop and box duplication by strippin…
aaronstevenwhite Jun 19, 2026
aba44e8
Merge pull request #167 from parafovea/refactor/phase-4-8-annotation-…
aaronstevenwhite Jun 19, 2026
cc4c627
Split the model-service vlm and detection loader modules into one-cla…
aaronstevenwhite Jun 19, 2026
7b82384
Merge pull request #168 from parafovea/refactor/phase-4-9-model-servi…
aaronstevenwhite Jun 19, 2026
7b88130
Consolidate the scattered demo-mode RBAC read-widening into a single …
aaronstevenwhite Jun 19, 2026
39fbf23
Merge pull request #169 from parafovea/refactor/phase-4-10-demo-gatin…
aaronstevenwhite Jun 19, 2026
62c0315
Decompose the 1403-line AnnotationWorkspace component into three cohe…
aaronstevenwhite Jun 20, 2026
abb35c2
Merge pull request #170 from parafovea/refactor/phase-4-8b-annotation…
aaronstevenwhite Jun 20, 2026
34e1f4b
Finalize the 0.5.0 changelog date to the release date.
aaronstevenwhite Jun 20, 2026
b627ef2
Wrap the literal video tag in the useAnnotationState return-type JSDo…
aaronstevenwhite Jun 20, 2026
8f4c60d
Reword the import-module JSDoc and the ImportResultDialog summary so …
aaronstevenwhite Jun 20, 2026
2a3749d
fix: resolve a batch of field-reported bugs (0.5.1) (#173)
aaronstevenwhite Jun 22, 2026
212884f
Finalize the 0.5.1 changelog date to the 2026-06-22 release date in b…
aaronstevenwhite Jun 22, 2026
07ada4c
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 22, 2026
bf61da5
Parse suffix and edge HTTP byte ranges per RFC 7233 in the video stre…
aaronstevenwhite Jun 22, 2026
75f95d0
Make clicking a claim card select it in place on the Claims tab
aaronstevenwhite Jun 24, 2026
ff1ac1b
Bump the version to 0.5.3 and record the claim-card selection fix in …
aaronstevenwhite Jun 24, 2026
2278ad3
fix: stamp project scope on summary and claim writes so collaborators…
aaronstevenwhite Jun 25, 2026
5ecf50a
Merge main into release/0.5.x to reconcile the squash-flattened linea…
aaronstevenwhite Jun 25, 2026
4b0e204
Merge main into release/0.5.x to reconcile the v0.5.4 squash-promote …
aaronstevenwhite Jun 27, 2026
51febf3
Fix ten audit-reported defects spanning group-delete ability-cache in…
aaronstevenwhite Jun 29, 2026
83ada36
Merge main into release/0.5.x to reconcile the v0.5.5 squash-promote …
aaronstevenwhite Jun 29, 2026
dd25365
Backend correctness and security hardening: authz/access-control fixe…
aaronstevenwhite Jun 30, 2026
4d60f3c
Merge main into release/0.5.x to reconcile the v0.5.6 squash-promote …
aaronstevenwhite Jun 30, 2026
dee0ea7
fix: frontend data-loss & cache hardening (v0.5.7) (#190)
aaronstevenwhite Jun 30, 2026
33a7601
Merge main into release/0.5.x to reconcile the v0.5.7 squash-promote …
aaronstevenwhite Jun 30, 2026
1e69a43
Harden the model-service: release VideoCapture handles on every exit …
aaronstevenwhite Jun 30, 2026
5f62f59
Merge main into release/0.5.x to reconcile the v0.5.8 squash-promote …
aaronstevenwhite Jun 30, 2026
c8c823f
Harden the backend against a second audit by requiring authentication…
aaronstevenwhite Jul 1, 2026
c1d2814
Merge main into release/0.5.x to reconcile the v0.5.9 squash-promote …
aaronstevenwhite Jul 1, 2026
c456a6d
Harden the annotation UI against the second audit by queuing a forced…
aaronstevenwhite Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.5.10] - 2026-07-01

The 0.5.10 patch is the second of three releases remediating the second swarm audit — the frontend slice — fixing auto-save data-loss and stale-cache defects in the annotation UI ([#196](https://github.com/parafovea/fovea/pull/196)). Several were gaps in the first audit's own frontend fixes, which closed one instance of a defect class without closing every instance. Nothing is breaking.

### Fixed

#### Auto-save no longer drops edits

- A forced save issued while another save was already in flight was silently dropped: the dialog's Done/Escape/backdrop flush, a keyframe override, and the session-expiry emergency flush could each return as if the write succeeded while persisting nothing. A forced save is now parked and drained once the in-flight save settles, and it reports whether it actually wrote (`annotation-tool/src/hooks/data/useAutoSave.ts`).
- Switching the persona in an open video-summary editor dropped the unsaved edits made under the previous persona — and, in the render window where the id had already changed but the loaded content had not, could persist one persona's text against another. The dialog now flushes the editor before switching and keys the editor by persona so it remounts cleanly for the new one (`annotation-tool/src/components/video/VideoSummaryDialog.tsx`, `VideoSummaryEditor.tsx`).
- The session-expiry emergency flush counted a no-op forced save (blocked by an in-flight save, skipped by change detection, or superseded) as a successful save, over-reporting preservation at the moment it matters most. It now counts only editors that actually persisted (`annotation-tool/src/hooks/data/autoSaveRegistry.ts`, `useAutoSave.ts`).
- Opening the editor fired a redundant auto-save of the just-loaded content, because the change baseline started empty and the initial sync looked like an edit. The editor now seeds the baseline to the adopted server content, so the first debounce tick sees no change (`annotation-tool/src/hooks/data/useAutoSave.ts`, `VideoSummaryEditor.tsx`).

#### Stale caches refreshed after mutations

- Deleting an ontology type from the persona editor left open annotation views and world panels showing annotations and per-persona type-assignments the server had already removed, because the delete hooks wired to the buttons never invalidated the annotation and world caches (only their unused twins did). Those four hooks now invalidate both (`annotation-tool/src/store/queries/usePersonas.ts`).
- A world collection or relation the user had just deleted could reappear after a quick follow-up add or edit: the add path built its whole-array PUT from a cache that still held the deleted object, and the merge-by-id server re-created it. The delete hooks now strip the object from the cache before any following write (`annotation-tool/src/store/queries/useWorld.ts`).
- Creating or deleting a project-scoped persona did not refresh the project's persona list, so the project detail page showed a stale roster within its two-minute cache window. Both mutations now invalidate the project-personas cache (`annotation-tool/src/store/queries/usePersonas.ts`).
- Accepting several AI ontology suggestions at once, or importing several Wikidata items quickly, left the ontology editor showing only a subset of the persisted types: the concurrent optimistic writes read the same base snapshot and the last one won. The add/update hooks now invalidate the per-persona ontology so the authoritative server state (merged by id) is refetched once the concurrent writes settle (`annotation-tool/src/store/queries/usePersonas.ts`).

## [0.5.9] - 2026-06-30

The 0.5.9 patch is the first of three releases remediating a second swarm audit of the shipped 0.5.x line — the backend slice — fixing authorization, lost-update concurrency, idempotency, and data-fidelity defects in the Fastify server ([#194](https://github.com/parafovea/fovea/pull/194)). Roughly a third of the findings were gaps in the first audit's own fixes: a fix addressed specific instances of a defect class without closing every instance. Nothing is breaking.
Expand Down
2 changes: 1 addition & 1 deletion annotation-tool/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@fovea/annotation-tool",
"private": true,
"version": "0.5.9",
"version": "0.5.10",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
21 changes: 20 additions & 1 deletion annotation-tool/src/components/video/VideoSummaryDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,19 @@ export default function VideoSummaryDialog({
onClose()
}

// Switching persona must first flush any unsaved edits made under the current
// persona — otherwise a mid-debounce edit is dropped, and worse, a debounce
// tick in the render window where the id has already changed could persist the
// previous persona's text against the new persona. Flush when dirty, then
// switch; the editor is also keyed by persona below, so it remounts cleanly
// for the new one rather than carrying the previous persona's local state.
const handlePersonaChange = async (value: string | null) => {
if (editorRef.current) {
await editorRef.current.flushIfDirty()
}
setSelectedPersonaId(value || null)
}

// Update selected persona when initial persona changes (e.g., when dialog
// opens). On a scrub-capture resume, prefer the draft's persona so the
// in-progress claim re-opens under the persona it was authored with.
Expand All @@ -87,7 +100,7 @@ export default function VideoSummaryDialog({
<Label htmlFor="summary-persona-select" className="mb-2 block">Select Persona</Label>
<Select
value={selectedPersonaId || ''}
onValueChange={(value) => setSelectedPersonaId(value || null)}
onValueChange={(value) => { void handlePersonaChange(value) }}
>
<SelectTrigger className="w-full" id="summary-persona-select">
{/* Explicit child override: the base-ui Select reads its
Expand Down Expand Up @@ -131,6 +144,12 @@ export default function VideoSummaryDialog({

{selectedPersonaId && videoId && (
<VideoSummaryEditor
// Key by persona so the editor remounts cleanly for each one: its
// load/empty-create/baseline-seed sequence re-runs from a fresh
// state instead of transitioning in place, which otherwise leaves a
// window where the previous persona's loaded summary and refs bleed
// into the newly selected persona's save.
key={selectedPersonaId}
ref={editorRef}
videoId={videoId}
personaId={selectedPersonaId}
Expand Down
52 changes: 35 additions & 17 deletions annotation-tool/src/components/video/VideoSummaryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ interface VideoSummaryEditorProps {
}

export interface VideoSummaryEditorRef {
/** Persist unconditionally (used on dialog close). */
forceSave: () => Promise<void>
/** Persist only if there are unsaved edits (used before a persona switch). */
flushIfDirty: () => Promise<void>
}

const VideoSummaryEditor = forwardRef<VideoSummaryEditorRef, VideoSummaryEditorProps>(function VideoSummaryEditor({
Expand Down Expand Up @@ -133,25 +136,28 @@ const VideoSummaryEditor = forwardRef<VideoSummaryEditorRef, VideoSummaryEditorP
// Fetch persona ontology via TanStack Query (auto-fetches when personaId changes)
usePersonaOntology(personaId)

// Autosave callback - memoized to prevent useAutoSave from re-triggering
const handleAutoSave = useCallback(async (summary: GlossItem[]) => {
// Autosave callback - memoized to prevent useAutoSave from re-triggering. The
// summary and comment are carried together as the autosave `data` so change
// detection and baseline seeding cover both fields from one snapshot (no
// getComparisonSnapshot closure over the comment, which would lag a render).
const handleAutoSave = useCallback(async ({ summary, comment }: { summary: GlossItem[]; comment: string }) => {
if (!currentSummary) {
// Create new summary - only required fields
await saveSummaryMutation.mutateAsync({ videoId, personaId, summary, comment: localComment.trim() || null })
await saveSummaryMutation.mutateAsync({ videoId, personaId, summary, comment: comment.trim() || null })
} else {
// Update existing summary - spread only defined optional fields
await saveSummaryMutation.mutateAsync({
videoId: currentSummary.videoId,
personaId: currentSummary.personaId,
summary,
comment: localComment.trim() || null,
comment: comment.trim() || null,
...(currentSummary.visualAnalysis && { visualAnalysis: currentSummary.visualAnalysis }),
...(currentSummary.audioTranscript && { audioTranscript: currentSummary.audioTranscript }),
...(currentSummary.keyFrames && { keyFrames: currentSummary.keyFrames }),
...(currentSummary.confidence != null && { confidence: currentSummary.confidence }),
})
}
}, [videoId, personaId, currentSummary, saveSummaryMutation, localComment])
}, [videoId, personaId, currentSummary, saveSummaryMutation])

// Use autosave hook for summary persistence
// Note: isEnabled doesn't need the ref check - the ref is only for preventing
Expand All @@ -161,14 +167,14 @@ const VideoSummaryEditor = forwardRef<VideoSummaryEditorRef, VideoSummaryEditorP
lastSavedAt,
errorMessage: saveErrorMessage,
retryCount,
pendingChanges,
forceSave,
markSaved,
} = useAutoSave({
data: localSummary,
// The comment lives in separate state from the summary `data`, so include
// it in the change-detection snapshot — otherwise a comment-only edit never
// trips the debounce and is silently dropped (handleAutoSave already sends
// the comment, it just was never triggered).
getComparisonSnapshot: (summary) => ({ summary, comment: localComment }),
// Summary and comment travel together so a comment-only edit trips the
// debounce (it lives in the compared snapshot) and the baseline can be
// seeded from one value on adoption. handleAutoSave receives both.
data: { summary: localSummary, comment: localComment },
// Autosave is enabled as soon as videoId + personaId resolve. The
// previous gate (&& !!summaryId) meant the dialog could only
// autosave AFTER a row already existed for this video/persona —
Expand All @@ -188,10 +194,15 @@ const VideoSummaryEditor = forwardRef<VideoSummaryEditorRef, VideoSummaryEditorP
entityId: `${videoId}-${personaId}`,
})

// Expose forceSave to parent components via ref
// Expose flush controls to the parent via ref (void-returning: callers only
// need the write to complete, not its outcome). `forceSave` persists
// unconditionally (dialog close); `flushIfDirty` persists only when there are
// unsaved edits, so a persona switch does not post a redundant empty save on
// every change of the dropdown.
useImperativeHandle(ref, () => ({
forceSave,
}), [forceSave])
forceSave: async () => { await forceSave() },
flushIfDirty: async () => { if (pendingChanges) await forceSave() },
}), [forceSave, pendingChanges])

// Track if we've already tried to create an empty summary for this video/persona
const creatingEmptySummaryRef = useRef<string | null>(null)
Expand Down Expand Up @@ -249,11 +260,15 @@ const VideoSummaryEditor = forwardRef<VideoSummaryEditorRef, VideoSummaryEditorP
const summaryData = typeof currentSummary.summary === 'string'
? (currentSummary.summary ? JSON.parse(currentSummary.summary) : [])
: (currentSummary.summary || [])
const commentData = currentSummary.comment || ''
setLocalSummary(summaryData)
setLocalComment(currentSummary.comment || '')
setLocalComment(commentData)
initializedForRef.current = key
// Seed the autosave baseline to the adopted server content so the initial
// sync is not mistaken for a user edit and does not fire a spurious save.
markSaved({ summary: summaryData, comment: commentData })
}
}, [videoId, personaId, currentSummary])
}, [videoId, personaId, currentSummary, markSaved])

// Separate effect for creating empty summary when none exists
// This is separate to avoid the mutation object in deps causing re-runs
Expand Down Expand Up @@ -287,14 +302,17 @@ const VideoSummaryEditor = forwardRef<VideoSummaryEditorRef, VideoSummaryEditorP
setLocalSummary([])
setLocalComment('')
initializedForRef.current = key
// The empty row is now persisted; seed the baseline so the next
// debounce tick does not re-POST the same empty content.
markSaved({ summary: [], comment: '' })
},
onError: () => {
// Reset so we can try again if needed
creatingEmptySummaryRef.current = null
},
})
}
}, [videoId, personaId, currentSummary, loading, queryError, saveSummaryMutation])
}, [videoId, personaId, currentSummary, loading, queryError, saveSummaryMutation, markSaved])

// Handle extraction job status updates from TanStack Query
useEffect(() => {
Expand Down
47 changes: 47 additions & 0 deletions annotation-tool/src/hooks/data/autoSaveRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Unit tests for the auto-save flush registry.
*/

import { describe, it, expect } from 'vitest'
import { registerAutoSaveFlush, flushAllAutoSaves } from './autoSaveRegistry'

describe('flushAllAutoSaves', () => {
it('counts only editors that actually persisted, not blocked/skipped/superseded no-ops', async () => {
// A resolved flush that wrote nothing (blocked by an in-flight save, skipped
// by change detection, or superseded by a newer force) must not be tallied
// as saved — otherwise the session-expiry flush over-reports preservation.
const unregister = [
registerAutoSaveFlush(async () => 'saved'),
registerAutoSaveFlush(async () => 'saved'),
registerAutoSaveFlush(async () => 'skipped'),
registerAutoSaveFlush(async () => 'blocked'),
registerAutoSaveFlush(async () => 'superseded'),
registerAutoSaveFlush(async () => {
throw new Error('boom')
}),
]

try {
const { saved, errors } = await flushAllAutoSaves()
expect(saved).toBe(2)
expect(errors).toHaveLength(1)
expect(errors[0].message).toBe('boom')
} finally {
unregister.forEach((u) => u())
}
})

it('reports zero saved when every editor no-ops', async () => {
const unregister = [
registerAutoSaveFlush(async () => 'blocked'),
registerAutoSaveFlush(async () => 'skipped'),
]
try {
const { saved, errors } = await flushAllAutoSaves()
expect(saved).toBe(0)
expect(errors).toHaveLength(0)
} finally {
unregister.forEach((u) => u())
}
})
})
19 changes: 13 additions & 6 deletions annotation-tool/src/hooks/data/autoSaveRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
* @module
*/

type FlushFn = () => Promise<void>
import type { SaveOutcome } from './useAutoSave'

type FlushFn = () => Promise<SaveOutcome>

const flushCallbacks = new Set<FlushFn>()

Expand All @@ -28,17 +30,22 @@ export function registerAutoSaveFlush(flush: FlushFn): () => void {

/**
* Flush every registered auto-save concurrently. Resolves with the count of
* callbacks that flushed without throwing and the errors from those that did,
* so a caller (e.g. emergency save) can report a real outcome rather than a
* no-op. Never rejects.
* editors that ACTUALLY persisted a write and the errors from those that
* failed, so a caller (e.g. emergency save) reports a real outcome rather than a
* no-op. A forced save that was change-skipped, blocked, or superseded resolves
* without throwing but wrote nothing, so it is not counted as saved — otherwise
* the session-expiry flush would over-report data preservation. Never rejects.
*
* @returns the number flushed successfully and any errors encountered
* @returns the number that actually persisted and any errors encountered
*/
export async function flushAllAutoSaves(): Promise<{ saved: number; errors: Error[] }> {
const callbacks = Array.from(flushCallbacks)
const results = await Promise.allSettled(callbacks.map((flush) => flush()))
const errors = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map((r) => (r.reason instanceof Error ? r.reason : new Error(String(r.reason))))
return { saved: results.length - errors.length, errors }
const saved = results.filter(
(r): r is PromiseFulfilledResult<SaveOutcome> => r.status === 'fulfilled' && r.value === 'saved'
).length
return { saved, errors }
}
87 changes: 87 additions & 0 deletions annotation-tool/src/hooks/data/useAutoSave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,4 +723,91 @@ describe('useAutoSave', () => {
}
})
})

describe('in-flight force queue, outcome, and baseline seeding', () => {
it('queues a forceSave issued while a save is in flight and drains it (never dropped)', async () => {
// Hold the first save open so a second forceSave lands while it runs.
let releaseFirst!: () => void
const first = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const onSave = vi
.fn<(data: { v: string }) => Promise<void>>()
.mockImplementationOnce(() => first)
.mockResolvedValue(undefined)

const { result } = renderHook(() =>
useAutoSave({ data: { v: 'render' }, isEnabled: true, onSave, entityType: 'annotation' })
)

let firstOutcome!: Promise<unknown>
let secondOutcome!: Promise<unknown>
act(() => {
firstOutcome = result.current.forceSave({ v: 'first' })
})
// While the first is still in flight, force again with new data.
act(() => {
secondOutcome = result.current.forceSave({ v: 'second' })
})

await act(async () => {
releaseFirst()
await firstOutcome
await secondOutcome
})

// Both writes land — the second was queued, not silently dropped.
expect(onSave).toHaveBeenCalledTimes(2)
expect(onSave).toHaveBeenNthCalledWith(1, { v: 'first' })
expect(onSave).toHaveBeenNthCalledWith(2, { v: 'second' })
})

it('forceSave resolves to "saved" on a real write', async () => {
const onSave = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ data: { v: 'x' }, isEnabled: true, onSave, entityType: 'annotation' })
)
let outcome: unknown
await act(async () => {
outcome = await result.current.forceSave()
})
expect(outcome).toBe('saved')
})

it('markSaved seeds the baseline so a debounce tick with the same data does not save', async () => {
vi.useFakeTimers()
try {
const onSave = vi.fn().mockResolvedValue(undefined)
const { result } = renderHook(() =>
useAutoSave({ data: { v: 'loaded' }, isEnabled: true, onSave, debounceMs: 100, entityType: 'summary' })
)
// The editor adopts server content and seeds the baseline to it.
act(() => {
result.current.markSaved({ v: 'loaded' })
})
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(onSave).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})

it('without markSaved, the initial debounce tick saves the just-loaded data (the spurious save markSaved fixes)', async () => {
vi.useFakeTimers()
try {
const onSave = vi.fn().mockResolvedValue(undefined)
renderHook(() =>
useAutoSave({ data: { v: 'loaded' }, isEnabled: true, onSave, debounceMs: 100, entityType: 'summary' })
)
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(onSave).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
})
Loading
Loading