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:
Anso
2026-05-24 23:44:24 -04:00
committed by GitHub
parent 668eda6cc8
commit 4964320f50
8 changed files with 442 additions and 41 deletions
@@ -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 ───────────────────────────────────────────