feat(services): playlist document store over the server API

Full documents are cached by ETag (weak tags included) and served as
private copies; an upsert whose payload is the one the store handed out
sends keepPayload=1 without it and retries in full on payload_missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 00:16:55 +02:00
1 parent b74584fdb2
commit ead47697bd
3 files changed
+280

No files matched your search

@@ -1,2 +1,3 @@
export * from './server-storage.config';
export * from './server-api-client';
export * from './server-playlist-document-store';
@@ -0,0 +1,102 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
import { ServerApiError } from './server-api-client';
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
function fakeResponse(status: number, body?: unknown, etag?: string) {
return {
status,
ok: status >= 200 && status < 300,
headers: new Headers(etag ? { etag } : {}),
json: async () => body,
} as unknown as Response;
}
describe('ServerPlaylistDocumentStore', () => {
const doc = {
_id: 'p1',
title: 'Family',
favorites: [],
playlist: { header: { raw: '#EXTM3U' }, items: [{ id: 'a' }] },
} as unknown as Playlist;
function setup() {
const api = {
request: jest.fn(),
getJson: jest.fn(),
sendJson: jest.fn(),
};
return { api, store: new ServerPlaylistDocumentStore(api as never) };
}
it('caches by ETag and serves a private copy on 304', async () => {
const { api, store } = setup();
api.request
.mockResolvedValueOnce(fakeResponse(200, doc, 'W/"b.0.1"'))
.mockResolvedValueOnce(fakeResponse(304));
const first = await store.dbGetAppPlaylist('p1');
const second = await store.dbGetAppPlaylist('p1');
expect(api.request.mock.calls[1][1]).toEqual({
headers: { 'If-None-Match': 'W/"b.0.1"' },
});
expect(second).toEqual(doc);
expect(second).not.toBe(first);
});
it('returns null for an unknown playlist', async () => {
const { api, store } = setup();
api.request.mockRejectedValue(
new ServerApiError(404, 'not_found', 'x')
);
await expect(store.dbGetAppPlaylist('zz')).resolves.toBeNull();
});
it('omits an unchanged payload and keeps it on the server', async () => {
const { api, store } = setup();
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
api.sendJson.mockResolvedValue(
fakeResponse(200, { success: true }, '"b.0.2"')
);
const served = await store.dbGetAppPlaylist('p1');
if (!served) throw new Error('expected a document');
await store.dbUpsertAppPlaylist({ ...served, favorites: ['a'] });
const [method, path, body] = api.sendJson.mock.calls[0];
expect([method, path]).toEqual(['PUT', '/playlists/p1?keepPayload=1']);
expect(body).toEqual({ _id: 'p1', title: 'Family', favorites: ['a'] });
});
it('uploads a new payload in full', async () => {
const { api, store } = setup();
api.sendJson.mockResolvedValue(
fakeResponse(200, { success: true }, '"b.0.1"')
);
await store.dbUpsertAppPlaylist(doc);
expect(api.sendJson.mock.calls[0][1]).toBe('/playlists/p1');
expect(api.sendJson.mock.calls[0][2]).toBe(doc);
});
it('retries in full when the server has no payload to keep', async () => {
const { api, store } = setup();
api.request.mockResolvedValueOnce(fakeResponse(200, doc, '"b.0.1"'));
const served = await store.dbGetAppPlaylist('p1');
if (!served) throw new Error('expected a document');
api.sendJson
.mockRejectedValueOnce(
new ServerApiError(409, 'payload_missing', 'x')
)
.mockResolvedValueOnce(
fakeResponse(200, { success: true }, '"b.1.1"')
);
await store.dbUpsertAppPlaylist(served);
expect(api.sendJson.mock.calls[1][1]).toBe('/playlists/p1');
expect(api.sendJson.mock.calls[1][2]).toBe(served);
});
});
@@ -0,0 +1,177 @@
import {
SERVER_PAYLOAD_FIELDS,
type ElectronBridgeCountResult,
type ElectronBridgeResult,
type Playlist,
} from '@iptvnator/shared/interfaces';
import type { PlaylistDocumentStore } from '../playlist-storage-backend';
import { ServerApiError, type ServerApiClient } from './server-api-client';
interface CachedDocument {
readonly etag: string;
readonly document: Playlist;
}
type ServerApi = Pick<ServerApiClient, 'request' | 'getJson' | 'sendJson'>;
/**
* `PlaylistDocumentStore` over the server storage API. Full documents are
* cached by ETag; every read hands out a private copy and remembers its
* payload objects, so an upsert whose payload is one of them omits it
* (`keepPayload=1`). `PlaylistsService` builds a new object per mutation,
* so cached documents are never mutated in place.
*/
export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
private readonly cache = new Map<string, CachedDocument>();
private readonly servedPayloads = new WeakSet<object>();
constructor(private readonly api: ServerApi) {}
dbGetAppPlaylists(): Promise<Playlist[]> {
return this.api.getJson<Playlist[]>('/playlists');
}
dbGetAppPlaylistMetas(): Promise<Playlist[]> {
return this.api.getJson<Playlist[]>('/playlists?view=meta');
}
async dbGetAppPlaylist(playlistId: string): Promise<Playlist | null> {
const cached = this.cache.get(playlistId);
let response: Response;
try {
response = await this.api.request(
`/playlists/${encodeURIComponent(playlistId)}`,
cached ? { headers: { 'If-None-Match': cached.etag } } : {}
);
} catch (error) {
if (error instanceof ServerApiError && error.status === 404) {
this.cache.delete(playlistId);
return null;
}
throw error;
}
if (response.status === 304 && cached) {
return this.serve(cached.document);
}
const document = (await response.json()) as Playlist;
this.remember(playlistId, response, document);
return this.serve(document);
}
async dbUpsertAppPlaylist(
playlist: Playlist
): Promise<ElectronBridgeResult> {
const path = `/playlists/${encodeURIComponent(playlist._id)}`;
const keepPayload = this.carriesServedPayload(playlist);
let response: Response;
try {
response = keepPayload
? await this.api.sendJson(
'PUT',
`${path}?keepPayload=1`,
withoutPayload(playlist)
)
: await this.api.sendJson('PUT', path, playlist);
} catch (error) {
if (!(
keepPayload &&
error instanceof ServerApiError &&
error.code === 'payload_missing'
)) {
throw error;
}
response = await this.api.sendJson('PUT', path, playlist);
}
this.remember(playlist._id, response, playlist);
return { success: true };
}
async dbUpsertAppPlaylists(
playlists: Playlist[]
): Promise<ElectronBridgeCountResult> {
for (const playlist of playlists) {
await this.dbUpsertAppPlaylist(playlist);
}
return { success: true, count: playlists.length };
}
async dbDeletePlaylist(playlistId: string): Promise<ElectronBridgeResult> {
await this.api.request(`/playlists/${encodeURIComponent(playlistId)}`, {
method: 'DELETE',
});
this.cache.delete(playlistId);
return { success: true };
}
async dbDeleteAllPlaylists(): Promise<ElectronBridgeResult> {
await this.api.request('/playlists', { method: 'DELETE' });
this.cache.clear();
return { success: true };
}
async dbGetAppState(key: string): Promise<string | null> {
const body = await this.api.getJson<{ value: string | null }>(
`/app-state/${encodeURIComponent(key)}`
);
return body.value;
}
async dbSetAppState(
key: string,
value: string
): Promise<ElectronBridgeResult> {
await this.api.sendJson(
'PUT',
`/app-state/${encodeURIComponent(key)}`,
{
value,
}
);
return { success: true };
}
private remember(id: string, response: Response, document: Playlist): void {
const etag = response.headers.get('etag');
if (etag) {
this.cache.set(id, { etag, document });
} else {
this.cache.delete(id);
}
}
private serve(document: Playlist): Playlist {
const copy = structuredClone(document);
const record = copy as unknown as Record<string, unknown>;
for (const field of SERVER_PAYLOAD_FIELDS) {
const value = record[field];
if (value && typeof value === 'object') {
this.servedPayloads.add(value);
}
}
return copy;
}
private carriesServedPayload(playlist: Playlist): boolean {
const record = playlist as unknown as Record<string, unknown>;
const present = SERVER_PAYLOAD_FIELDS.filter(
(field) => record[field] !== undefined && record[field] !== null
);
return (
present.length > 0 &&
present.every(
(field) =>
typeof record[field] === 'object' &&
this.servedPayloads.has(record[field] as object)
)
);
}
}
function withoutPayload(playlist: Playlist): Record<string, unknown> {
const copy = { ...playlist } as Record<string, unknown>;
for (const field of SERVER_PAYLOAD_FIELDS) {
delete copy[field];
}
return copy;
}