fix(server-storage): the move uploads playlists create-only and merges into a copy moved first

Two tabs or devices moving browser copies with the same playlist id both
planned an upload; the browser documents carry no served version, so
both PUTs were unconditional and the later one overwrote the first copy
(and any change made on it since), with both moves reporting success.

The backend's playlist PUT now honours `If-None-Match: *` (412 when the
playlist exists; checked and written in one synchronous handler). The
move uploads with it through ServerPlaylistDocumentStore
.dbCreateAppPlaylist, and an upload that finds its id taken merges its
favorites and history into that copy instead, counted as merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 20:06:18 +02:00
1 parent f6a216f59a
commit 954a335f31
7 files changed
+129 -12

No files matched your search

@@ -64,6 +64,15 @@ export function createPlaylistsRouter(
'The playlist changed since it was read.'
);
}
// A create-only upload (a move from browser storage) that another
// tab or device made first. Checked and written in one handler.
if (req.get('if-none-match')?.trim() === '*' && playlists.exists(id)) {
throw new StorageRequestError(
412,
'conflict',
'A playlist with this id exists already.'
);
}
playlists.upsert(
{ ...requireDocument(req.body), _id: id },
{ keepPayload: req.query['keepPayload'] === '1' }
@@ -251,6 +251,35 @@ describe('server storage routes', () => {
})
));
it('creates a playlist create-only, refused once it exists', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
const create = (title: string) =>
fetch(
`${baseUrl}/v1/playlists/p1`,
json(
{ ...m3uDocument('p1'), title },
{
method: 'PUT',
headers: { 'if-none-match': '*' },
}
)
);
expect((await create('First')).status).toBe(200);
// Another tab or device moved the same playlist first.
const second = await create('Second');
expect(second.status).toBe(412);
await expect(second.json()).resolves.toMatchObject({
code: 'conflict',
});
const stored = await fetch(`${baseUrl}/v1/playlists/p1`);
await expect(stored.json()).resolves.toMatchObject({
title: 'First',
});
})
));
it('writes user state only on the tag it was read at', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {