mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag (#1206)
* fix(stack-files): optimistic concurrency on file-tab writes via mtime ETag PUT /api/stacks/:name/files/content previously did a blind write; two operators editing the same script lost one of the saves with no warning. The compose-file editor already had mtime optimistic concurrency (PR #1183); this brings the file-explorer write path to the same shape. GET /files/content now also returns mtimeMs and sets a weak ETag header derived from the stat. The matching PUT reads If-Match, asks FileSystemService.writeStackFileIfUnchanged to compare against the live mtime, and returns 412 PRECONDITION_FAILED with the current content and mtime when the stale-write check fails. Successful writes echo a fresh ETag so the client can pin the next save without re-GET. readStackFile and writeStackFileIfUnchanged each open the file once and stat+read through the same handle so the mtime returned to the client matches the bytes that were sent, even if the file is replaced between the two operations. PUT without If-Match still succeeds (backward compatibility with scripted clients that do not roundtrip the ETag). FileViewer now sends the loaded mtime on save, updates its local mtime from the success response, and on FileConflictError adopts the server snapshot as the new baseline so the user's follow-up edit-and-save does not loop on the same precondition. * fix(stack-files): treat deleted-target as conflict; preserve user buffer on conflict Two follow-ups from code review on the prior commit: - writeStackFileIfUnchanged now returns ok:false when expectedMtimeMs is set and the target has been deleted. The caller was editing a file that no longer exists; silently writing the buffer to the void is wrong. The client adopts the empty snapshot as 'file is gone, start over' and the user keeps control of what to save next. - The FileViewer conflict handler no longer overwrites the user's typed buffer with the server snapshot. It updates the baseline so the next save sends the fresh mtime, then leaves the editor content alone. The user sees their edits, the Save button stays enabled, and a follow-up click applies their changes on top of the new server version without silently destroying what they typed. * fix(api): preserve default headers when caller supplies a headers field apiFetch built defaultOptions.headers by merging Content-Type, x-node-id, and the caller's headers, but then spread the unmodified fetchOptions over defaultOptions at the outer level. The spread overwrote the merged headers with the caller's bare headers, silently dropping Content-Type on every request that supplied any custom header. This was latent until the file-explorer save path started sending an If-Match header. The Express body parser refused the PUT without Content-Type, the route returned 400, the editor showed an error toast instead of the success toast, and the Playwright save assertion timed out. Destructure headers out of fetchOptions before the outer spread so the already-merged defaultOptions.headers survives. Add api.test.ts with four regression cases pinning Content-Type, the If-Match merge, x-node-id presence when active, and localOnly skip.
This commit is contained in:
@@ -4,7 +4,7 @@ import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { readStackFile, writeStackFile, downloadStackFile } from '@/lib/stackFilesApi';
|
||||
import { readStackFile, writeStackFile, downloadStackFile, FileConflictError } from '@/lib/stackFilesApi';
|
||||
import { extensionToLanguage } from '@/lib/monacoLanguages';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
|
||||
@@ -102,6 +102,7 @@ export function FileViewer({
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const [isOversized, setIsOversized] = useState(false);
|
||||
const [size, setSize] = useState(0);
|
||||
const [loadedMtimeMs, setLoadedMtimeMs] = useState<number | null>(null);
|
||||
|
||||
const readOnly = !canEdit;
|
||||
const hasChanges = content !== originalContent;
|
||||
@@ -154,6 +155,7 @@ export function FileViewer({
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setSize(result.size);
|
||||
setLoadedMtimeMs(result.mtimeMs);
|
||||
if (result.binary) {
|
||||
setIsBinary(true);
|
||||
} else if (result.oversized) {
|
||||
@@ -182,12 +184,27 @@ export function FileViewer({
|
||||
setSaving(true);
|
||||
const loadingId = toast.loading('Saving...');
|
||||
try {
|
||||
await writeStackFile(stackName, selectedPath, content);
|
||||
const result = await writeStackFile(stackName, selectedPath, content, {
|
||||
ifMatchMtimeMs: loadedMtimeMs ?? undefined,
|
||||
});
|
||||
setOriginalContent(content);
|
||||
if (result.mtimeMs !== null) setLoadedMtimeMs(result.mtimeMs);
|
||||
toast.success('Saved.');
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Save failed.');
|
||||
if (e instanceof FileConflictError) {
|
||||
// The server-side content has moved on. Update the baseline (so the
|
||||
// next save sends the fresh mtime and stops looping on the same
|
||||
// precondition) but leave the user's typed buffer untouched. Their
|
||||
// edits remain in the editor, Save stays enabled, and a follow-up
|
||||
// click will apply their changes on top of the new server content
|
||||
// without silently destroying what they typed.
|
||||
setOriginalContent(e.currentContent);
|
||||
setLoadedMtimeMs(e.currentMtimeMs);
|
||||
toast.error('File changed elsewhere. Review your edits then save again to apply them on top of the current version.');
|
||||
} else {
|
||||
toast.error(e instanceof Error ? e.message : 'Save failed.');
|
||||
}
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
setSaving(false);
|
||||
|
||||
@@ -11,17 +11,43 @@ import type { FileContentResult } from '@/lib/stackFilesApi';
|
||||
|
||||
// FileViewer now imports `Editor` from the lazy loader, not directly from
|
||||
// @monaco-editor/react. Mock the loader so tests skip Monaco's setup path
|
||||
// and the editor renders synchronously.
|
||||
// and the editor renders synchronously. The mock exposes a hidden button
|
||||
// that calls onChange so tests can drive a dirty buffer without instantiating
|
||||
// the real editor.
|
||||
vi.mock('@/lib/monacoLoader', () => ({
|
||||
Editor: () => <div data-testid="monaco-editor" />,
|
||||
Editor: ({ onChange }: { onChange?: (value: string | undefined) => void }) => (
|
||||
<div data-testid="monaco-editor">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="monaco-edit-trigger"
|
||||
onClick={() => onChange?.('edited content')}
|
||||
>
|
||||
edit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
DiffEditor: () => <div data-testid="monaco-diff-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', () => ({
|
||||
readStackFile: vi.fn(),
|
||||
writeStackFile: vi.fn(),
|
||||
downloadStackFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/stackFilesApi', () => {
|
||||
class MockFileConflictError extends Error {
|
||||
readonly code = 'PRECONDITION_FAILED' as const;
|
||||
readonly currentContent: string;
|
||||
readonly currentMtimeMs: number;
|
||||
constructor(message: string, currentContent: string, currentMtimeMs: number) {
|
||||
super(message);
|
||||
this.name = 'FileConflictError';
|
||||
this.currentContent = currentContent;
|
||||
this.currentMtimeMs = currentMtimeMs;
|
||||
}
|
||||
}
|
||||
return {
|
||||
readStackFile: vi.fn(),
|
||||
writeStackFile: vi.fn(),
|
||||
downloadStackFile: vi.fn(),
|
||||
FileConflictError: MockFileConflictError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
@@ -63,21 +89,22 @@ vi.mock('@/lib/monacoLanguages', () => ({
|
||||
extensionToLanguage: () => 'plaintext',
|
||||
}));
|
||||
|
||||
import { readStackFile } from '@/lib/stackFilesApi';
|
||||
import { readStackFile, writeStackFile, FileConflictError } from '@/lib/stackFilesApi';
|
||||
import { FileViewer } from '../FileViewer';
|
||||
|
||||
const mockReadFile = readStackFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockWriteFile = writeStackFile as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function textResult(content = 'hello world'): FileContentResult {
|
||||
return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain' };
|
||||
return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain', mtimeMs: 1_700_000_000_000 };
|
||||
}
|
||||
|
||||
function binaryResult(): FileContentResult {
|
||||
return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream' };
|
||||
return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream', mtimeMs: 1_700_000_000_000 };
|
||||
}
|
||||
|
||||
function oversizedResult(): FileContentResult {
|
||||
return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain' };
|
||||
return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain', mtimeMs: 1_700_000_000_000 };
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
@@ -88,6 +115,7 @@ const defaultProps = {
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
mockWriteFile.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -188,4 +216,49 @@ describe('FileViewer', () => {
|
||||
|
||||
expect(onDirtyChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('sends If-Match with the loaded mtime on save and updates the local mtime from the response', async () => {
|
||||
mockReadFile.mockResolvedValue(textResult('hello'));
|
||||
mockWriteFile.mockResolvedValue({ mtimeMs: 1_700_000_000_999 });
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="config.txt" />);
|
||||
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
|
||||
|
||||
// Drive a dirty buffer via the mock editor's edit trigger so Save activates.
|
||||
screen.getByTestId('monaco-edit-trigger').click();
|
||||
const saveBtn = screen.getByRole('button', { name: /save/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
saveBtn.click();
|
||||
|
||||
await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(1));
|
||||
const [s, p, c, opts] = mockWriteFile.mock.calls[0];
|
||||
expect(s).toBe('my-stack');
|
||||
expect(p).toBe('config.txt');
|
||||
expect(c).toBe('edited content');
|
||||
expect(opts).toEqual({ ifMatchMtimeMs: 1_700_000_000_000 });
|
||||
});
|
||||
|
||||
it('updates baseline on FileConflictError without discarding the user buffer; follow-up save uses new mtime', async () => {
|
||||
mockReadFile.mockResolvedValue(textResult('stale local copy'));
|
||||
mockWriteFile
|
||||
.mockRejectedValueOnce(new FileConflictError('changed elsewhere', 'SERVER NOW', 1_700_000_999_000))
|
||||
.mockResolvedValueOnce({ mtimeMs: 1_700_001_000_000 });
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="config.txt" />);
|
||||
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
|
||||
screen.getByTestId('monaco-edit-trigger').click();
|
||||
const saveBtn = screen.getByRole('button', { name: /save/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
saveBtn.click();
|
||||
|
||||
await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The follow-up save sends the mtime from the conflict response so the
|
||||
// user does not loop on the same stale precondition. The user's typed
|
||||
// content ('edited content' from the mock trigger) is preserved on top.
|
||||
saveBtn.click();
|
||||
await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(2));
|
||||
expect(mockWriteFile.mock.calls[1][2]).toBe('edited content');
|
||||
expect(mockWriteFile.mock.calls[1][3]).toEqual({ ifMatchMtimeMs: 1_700_000_999_000 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user