mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +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:
@@ -170,6 +170,17 @@ describe('GET /api/stacks/:stackName/files/content', () => {
|
||||
expect(res.body.binary).toBe(false);
|
||||
expect(res.body.oversized).toBe(false);
|
||||
expect(typeof res.body.content).toBe('string');
|
||||
expect(typeof res.body.mtimeMs).toBe('number');
|
||||
expect(res.body.mtimeMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('sets a quoted ETag header derived from mtimeMs', async () => {
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['etag']).toMatch(/^W\/"\d+"$/);
|
||||
});
|
||||
|
||||
it('returns 404 for a non-existent file', async () => {
|
||||
@@ -432,6 +443,103 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
|
||||
const content = await fs.readFile(path.join(stacksDir, STACK, 'written.txt'), 'utf-8');
|
||||
expect(content).toBe('written via PUT');
|
||||
});
|
||||
|
||||
it('echoes a fresh ETag header on successful write', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'etag-write.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'etag write' });
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers['etag']).toMatch(/^W\/"\d+"$/);
|
||||
});
|
||||
|
||||
it('returns 412 when If-Match disagrees with the current mtimeMs and surfaces the live content', async () => {
|
||||
// Seed the target so a known mtime exists.
|
||||
const target = path.join(stacksDir, STACK, 'mtime-target.txt');
|
||||
await fs.writeFile(target, 'ORIGINAL');
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'mtime-target.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.set('If-Match', '"1"') // deliberately stale
|
||||
.send({ content: 'overwrite attempt' });
|
||||
|
||||
expect(res.status).toBe(412);
|
||||
expect(res.body.code).toBe('PRECONDITION_FAILED');
|
||||
expect(res.body.currentContent).toBe('ORIGINAL');
|
||||
expect(typeof res.body.currentMtimeMs).toBe('number');
|
||||
|
||||
// Disk content is unchanged.
|
||||
const after = await fs.readFile(target, 'utf-8');
|
||||
expect(after).toBe('ORIGINAL');
|
||||
});
|
||||
|
||||
it('succeeds when If-Match matches the current mtimeMs', async () => {
|
||||
const target = path.join(stacksDir, STACK, 'mtime-match.txt');
|
||||
await fs.writeFile(target, 'first');
|
||||
const getRes = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'mtime-match.txt' })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(getRes.status).toBe(200);
|
||||
const etag = getRes.headers['etag'];
|
||||
expect(etag).toBeDefined();
|
||||
|
||||
const putRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'mtime-match.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.set('If-Match', etag)
|
||||
.send({ content: 'second' });
|
||||
expect(putRes.status).toBe(204);
|
||||
expect(putRes.headers['etag']).toBeDefined();
|
||||
expect(putRes.headers['etag']).not.toBe(etag);
|
||||
|
||||
const after = await fs.readFile(target, 'utf-8');
|
||||
expect(after).toBe('second');
|
||||
});
|
||||
|
||||
it('still writes when no If-Match header is sent (backward compat)', async () => {
|
||||
const target = path.join(stacksDir, STACK, 'no-ifmatch.txt');
|
||||
await fs.writeFile(target, 'before');
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'no-ifmatch.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'after' });
|
||||
expect(res.status).toBe(204);
|
||||
const after = await fs.readFile(target, 'utf-8');
|
||||
expect(after).toBe('after');
|
||||
});
|
||||
|
||||
it('returns 412 with an empty snapshot when If-Match was set but the target has been deleted', async () => {
|
||||
// No file at this path; the caller's If-Match implies an existing file.
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'vanished.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.set('If-Match', '"42"')
|
||||
.send({ content: 'i was editing this' });
|
||||
expect(res.status).toBe(412);
|
||||
expect(res.body.code).toBe('PRECONDITION_FAILED');
|
||||
expect(res.body.currentContent).toBe('');
|
||||
expect(res.body.currentMtimeMs).toBe(0);
|
||||
// Nothing was written.
|
||||
await expect(fs.access(path.join(stacksDir, STACK, 'vanished.txt'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('still writes a fresh file when no If-Match is sent (target does not exist)', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'brand-new.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'first content' });
|
||||
expect(res.status).toBe(204);
|
||||
const content = await fs.readFile(path.join(stacksDir, STACK, 'brand-new.txt'), 'utf-8');
|
||||
expect(content).toBe('first content');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PATCH /:stackName/files/rename ───────────────────────────────────────────
|
||||
|
||||
@@ -1405,6 +1405,10 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response
|
||||
logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId });
|
||||
try {
|
||||
const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath);
|
||||
// ETag is the integer mtimeMs the file was stat'd with, so the matching
|
||||
// PUT can compare millisecond-equal even though some filesystems return
|
||||
// float mtimeMs.
|
||||
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
|
||||
logFileDiag('read complete', {
|
||||
stackName,
|
||||
relPath,
|
||||
@@ -1521,10 +1525,28 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: '"content" must be a string' });
|
||||
}
|
||||
const expectedMtimeMs = parseIfMatchMtime(req.header('if-match'));
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8') });
|
||||
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8'), hasIfMatch: expectedMtimeMs !== null });
|
||||
try {
|
||||
await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content);
|
||||
const result = await FileSystemService.getInstance(req.nodeId).writeStackFileIfUnchanged(
|
||||
stackName,
|
||||
relPath,
|
||||
content,
|
||||
expectedMtimeMs,
|
||||
);
|
||||
if (!result.ok) {
|
||||
// Stale ETag: surface the current content + mtime so the client can
|
||||
// show a "file changed elsewhere" diff and let the user reconcile.
|
||||
res.setHeader('ETag', stackFileEtag(result.currentMtimeMs));
|
||||
return res.status(412).json({
|
||||
error: 'File has been modified since you last read it. Reload to see the current version.',
|
||||
code: 'PRECONDITION_FAILED',
|
||||
currentMtimeMs: result.currentMtimeMs,
|
||||
currentContent: result.currentContent,
|
||||
});
|
||||
}
|
||||
res.setHeader('ETag', stackFileEtag(result.mtimeMs));
|
||||
logFileOperation('info', 'write complete', { nodeId: req.nodeId });
|
||||
logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt });
|
||||
return res.status(204).send();
|
||||
|
||||
@@ -654,31 +654,39 @@ export class FileSystemService {
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
maxBytes: number = 2 * 1024 * 1024
|
||||
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string }> {
|
||||
): Promise<{ content?: string; binary: boolean; oversized: boolean; size: number; mime: string; mtimeMs: number }> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
const stat = await fsPromises.stat(safePath);
|
||||
const mime = this.guessMime(safePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
|
||||
// Open once and stat+read through the same handle so the mtime returned to
|
||||
// the client matches the bytes it received, even if the file is replaced
|
||||
// (atomic rename) between the two operations.
|
||||
const fh = await fsPromises.open(safePath, 'r');
|
||||
try {
|
||||
const stat = await fh.stat();
|
||||
const mtimeMs = stat.mtimeMs;
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw Object.assign(new Error('Target is a directory'), { code: 'IS_DIRECTORY' });
|
||||
}
|
||||
|
||||
if (stat.size > maxBytes) {
|
||||
const probe = Buffer.allocUnsafe(8192);
|
||||
const { bytesRead } = await fh.read(probe, 0, 8192, 0);
|
||||
const binary = isBinaryBuffer(probe.subarray(0, bytesRead));
|
||||
return { binary, oversized: true, size: stat.size, mime, mtimeMs };
|
||||
}
|
||||
|
||||
const buf = await fh.readFile();
|
||||
|
||||
if (isBinaryBuffer(buf)) {
|
||||
return { binary: true, oversized: false, size: stat.size, mime, mtimeMs };
|
||||
}
|
||||
|
||||
return { binary: false, oversized: false, size: stat.size, mime, mtimeMs, content: buf.toString('utf-8') };
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
|
||||
if (stat.size > maxBytes) {
|
||||
const fd = await fsPromises.open(safePath, 'r');
|
||||
const probe = Buffer.allocUnsafe(8192);
|
||||
const { bytesRead } = await fd.read(probe, 0, 8192, 0);
|
||||
await fd.close();
|
||||
const binary = isBinaryBuffer(probe.subarray(0, bytesRead));
|
||||
return { binary, oversized: true, size: stat.size, mime };
|
||||
}
|
||||
|
||||
const buf = await fsPromises.readFile(safePath);
|
||||
|
||||
if (isBinaryBuffer(buf)) {
|
||||
return { binary: true, oversized: false, size: stat.size, mime };
|
||||
}
|
||||
|
||||
return { binary: false, oversized: false, size: stat.size, mime, content: buf.toString('utf-8') };
|
||||
}
|
||||
|
||||
async streamStackFile(
|
||||
@@ -795,6 +803,58 @@ export class FileSystemService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic-concurrency write for arbitrary stack files (file-explorer
|
||||
* editor save path). If `expectedMtimeMs` is provided, opens the target,
|
||||
* stats it, and refuses the write (returning current content + mtime) when
|
||||
* the stat does not match the caller's expectation. Mirrors the
|
||||
* compose-file pattern in saveStackContentIfUnchanged.
|
||||
*
|
||||
* Mtime comparison uses Math.floor so sub-millisecond jitter between
|
||||
* different filesystems and Node versions does not produce false 412s.
|
||||
*
|
||||
* If the file does not exist yet, the write proceeds (no mtime to compare).
|
||||
* Returns the new mtimeMs so the route can emit a fresh ETag.
|
||||
*/
|
||||
async writeStackFileIfUnchanged(
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
content: string,
|
||||
expectedMtimeMs: number | null,
|
||||
): Promise<
|
||||
| { ok: true; mtimeMs: number }
|
||||
| { ok: false; currentMtimeMs: number; currentContent: string }
|
||||
> {
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
await fsPromises.mkdir(path.dirname(safePath), { recursive: true });
|
||||
|
||||
if (expectedMtimeMs !== null) {
|
||||
let fh: import('fs/promises').FileHandle | null = null;
|
||||
try {
|
||||
fh = await fsPromises.open(safePath, 'r');
|
||||
const stat = await fh.stat();
|
||||
if (Math.floor(stat.mtimeMs) !== Math.floor(expectedMtimeMs)) {
|
||||
const currentContent = await fh.readFile('utf-8');
|
||||
return { ok: false, currentMtimeMs: stat.mtimeMs, currentContent };
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
// The caller expected an existing file but it has been deleted under
|
||||
// the editor. That is itself a conflict (the file is gone, the user
|
||||
// is editing into a void) so surface it the same way as a stale-mtime
|
||||
// mismatch. An empty current snapshot tells the client "the live
|
||||
// version is gone, you are starting from scratch".
|
||||
return { ok: false, currentMtimeMs: 0, currentContent: '' };
|
||||
} finally {
|
||||
if (fh) await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
await fsPromises.writeFile(safePath, content, 'utf-8');
|
||||
const newStat = await fsPromises.stat(safePath);
|
||||
return { ok: true, mtimeMs: newStat.mtimeMs };
|
||||
}
|
||||
|
||||
async deleteStackPath(stackName: string, relPath: string, recursive: boolean = false): Promise<void> {
|
||||
if (isProtectedRelPath(relPath)) throw protectedFileError(relPath);
|
||||
const safePath = await this.resolveSafeStackPath(stackName, relPath);
|
||||
|
||||
@@ -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