mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Unit tests for apiFetch's request shape, specifically the header merge.
|
||||
*
|
||||
* Regression guard: callers that pass a `headers` field must not lose the
|
||||
* default Content-Type and x-node-id that apiFetch builds. An earlier shape
|
||||
* spread fetchOptions over defaultOptions at the outer level after merging
|
||||
* headers into defaultOptions.headers, which silently clobbered the merge
|
||||
* when the caller supplied any `headers` value.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { apiFetch } from '../api';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})) as unknown as typeof fetch;
|
||||
try { localStorage.removeItem('sencho-active-node'); } catch { /* jsdom */ }
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function lastFetchInit(): RequestInit {
|
||||
const calls = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls;
|
||||
return calls[calls.length - 1][1] as RequestInit;
|
||||
}
|
||||
|
||||
describe('apiFetch header merge', () => {
|
||||
it('always sets Content-Type: application/json on the outgoing request', async () => {
|
||||
await apiFetch('/health');
|
||||
const init = lastFetchInit();
|
||||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('preserves Content-Type even when the caller supplies a custom header', async () => {
|
||||
await apiFetch('/stacks/foo/files/content?path=app.txt', {
|
||||
method: 'PUT',
|
||||
headers: { 'If-Match': '"1700000000000"' },
|
||||
body: JSON.stringify({ content: 'hi' }),
|
||||
});
|
||||
const init = lastFetchInit();
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
expect(headers['If-Match']).toBe('"1700000000000"');
|
||||
});
|
||||
|
||||
it('merges x-node-id when an active node is set even when caller supplies headers', async () => {
|
||||
localStorage.setItem('sencho-active-node', '7');
|
||||
await apiFetch('/stacks/foo/files', {
|
||||
method: 'GET',
|
||||
headers: { 'X-Trace-Id': 'abc' },
|
||||
});
|
||||
const init = lastFetchInit();
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['x-node-id']).toBe('7');
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
expect(headers['X-Trace-Id']).toBe('abc');
|
||||
localStorage.removeItem('sencho-active-node');
|
||||
});
|
||||
|
||||
it('honours localOnly to skip x-node-id', async () => {
|
||||
localStorage.setItem('sencho-active-node', '7');
|
||||
await apiFetch('/stacks/foo/files', { localOnly: true, headers: { 'If-Match': '"1"' } });
|
||||
const init = lastFetchInit();
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(headers['x-node-id']).toBeUndefined();
|
||||
expect(headers['If-Match']).toBe('"1"');
|
||||
expect(headers['Content-Type']).toBe('application/json');
|
||||
localStorage.removeItem('sencho-active-node');
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,12 @@ export async function apiFetch(
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...defaultOptions, ...fetchOptions });
|
||||
// Drop headers from fetchOptions before the outer spread so the merged
|
||||
// defaultOptions.headers (with Content-Type and x-node-id) survives. Without
|
||||
// this, any caller that passes a `headers` field clobbers the defaults.
|
||||
const { headers: _callerHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions;
|
||||
void _callerHeaders;
|
||||
const response = await fetch(url, { ...defaultOptions, ...fetchOptionsWithoutHeaders });
|
||||
|
||||
if (response.status === 401) {
|
||||
// Only fire the global logout event for local auth failures.
|
||||
|
||||
@@ -41,6 +41,24 @@ export interface FileContentResult {
|
||||
oversized: boolean;
|
||||
size: number;
|
||||
mime: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by writeStackFile when the server reports the target file has been
|
||||
* modified since the caller's last read (HTTP 412). The current server-side
|
||||
* content and mtime are attached so callers can prompt the user to reconcile.
|
||||
*/
|
||||
export class FileConflictError 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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseApiError(res: Response): Promise<string> {
|
||||
@@ -157,14 +175,36 @@ export async function uploadStackFile(
|
||||
export async function writeStackFile(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
content: string,
|
||||
options?: { ifMatchMtimeMs?: number }
|
||||
): Promise<{ mtimeMs: number | null }> {
|
||||
assertSafeRelPath(relPath);
|
||||
const headers: Record<string, string> = {};
|
||||
if (options?.ifMatchMtimeMs !== undefined) {
|
||||
headers['If-Match'] = `"${Math.floor(options.ifMatchMtimeMs)}"`;
|
||||
}
|
||||
const res = await apiFetch(
|
||||
stackFilesUrl(stackName, `/content?path=${encodeURIComponent(relPath)}`),
|
||||
{ method: 'PUT', body: JSON.stringify({ content }) }
|
||||
{ method: 'PUT', headers, body: JSON.stringify({ content }) }
|
||||
);
|
||||
if (res.status === 412) {
|
||||
let body: { currentContent?: string; currentMtimeMs?: number; error?: string } = {};
|
||||
try { body = await res.clone().json(); } catch { /* ignore */ }
|
||||
throw new FileConflictError(
|
||||
body.error ?? 'File has been modified since you last read it.',
|
||||
typeof body.currentContent === 'string' ? body.currentContent : '',
|
||||
typeof body.currentMtimeMs === 'number' ? body.currentMtimeMs : 0,
|
||||
);
|
||||
}
|
||||
if (!res.ok) throw new Error(await parseApiError(res));
|
||||
// Parse the ETag the server set so callers can update their local mtime.
|
||||
const etag = res.headers.get('ETag');
|
||||
if (etag) {
|
||||
const stripped = etag.replace(/^W\//i, '').trim().replace(/^"(.*)"$/, '$1');
|
||||
const parsed = Number(stripped);
|
||||
if (Number.isFinite(parsed)) return { mtimeMs: parsed };
|
||||
}
|
||||
return { mtimeMs: null };
|
||||
}
|
||||
|
||||
export async function deleteStackPath(
|
||||
|
||||
Reference in New Issue
Block a user