fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move

The Stalker metadata migration wrote server rows back through the
unconditional bulk upsert, from the list read: a device that edited a
Stalker playlist in between (or a second browser migrating the same
copied database) lost its change. Server rows are now read again one by
one and written as conditional mutations on that version; a conflict
leaves the flag unset, so the next start retries.

Moving a document's history kept the server's copy of an item both held,
even when the browser had viewed it later, and skipped the write when the
length did not change. The combined history is now newest first by
added_at, one entry per item, and any content change is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 15:19:13 +02:00
1 parent 176108b682
commit 797aeae307
6 files changed
+170 -63

No files matched your search

+4 -2
View File
@@ -357,13 +357,15 @@ settings and guards read them in every build.
playlists this browser kept in IndexedDB (`planServerMigration`), Stalker
rows from before the portal-mode flag normalized first (server rows get
the same Stalker metadata migration once per server, as a desktop
database copied there can predate it). A
database copied there can predate it: each row read again and written as
a conditional mutation, a conflict retried at the next start). A
playlist the server already has, by id or by source (Xtream server and
user, Stalker portal and MAC, M3U URL), merges into the server's copy; the
others upload with their ids. A merged playlist gains the browser
document's favorites and history (M3U favorites name channels by stream
URL; older ones by a channel id that is random per import and is
translated through the URL; Stalker items by id, category and series
translated through the URL; recently viewed newest first by `added_at`,
one per item with its newest view; Stalker items by id, category and series
flag, since one provider category can hold a movie and a series of one
id; nothing is dropped), and the legacy Xtream maps follow under
the server id; each copy's bare legacy Xtream ids are typed from its own
+73 -48
View File
@@ -578,58 +578,83 @@ describe('PlaylistsService', () => {
);
});
it('migrates legacy Stalker portal flags on the server too', async () => {
describe('legacy Stalker portal flags on the server', () => {
// A desktop database copied to the server, from before the flag.
let storedPlaylists: Playlist[] = [
{
_id: 'stalker-1',
title: 'Copied Stalker',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'http://example.com/stalker_portal/c/',
} as Playlist,
];
const appState = new Map<string, string>();
const server = {
dbGetAppPlaylists: jest.fn(async () => storedPlaylists),
dbGetAppState: jest.fn(
async (key: string) => appState.get(key) ?? null
),
dbSetAppState: jest.fn(async (key: string, value: string) => {
appState.set(key, value);
}),
dbUpsertAppPlaylist: jest.fn(),
dbUpsertAppPlaylists: jest.fn(async (playlists: Playlist[]) => {
storedPlaylists = playlists;
}),
dbRecoverLegacyPlaylists: jest.fn(),
};
testWindow.electron = undefined;
const service = createService();
Object.assign(service as object, {
storageBackend: {
kind: () => 'server',
documentStore: () => server,
},
const copied = {
_id: 'stalker-1',
title: 'Copied Stalker',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
lastUsage: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'http://example.com/stalker_portal/c/',
} as Playlist;
function serverService(
fresh: Playlist,
upsert: jest.Mock = jest.fn(async () => ({ success: true }))
) {
const appState = new Map<string, string>();
const server = {
dbGetAppPlaylists: jest.fn(async () => [copied]),
// Another device may have changed it since the list read.
dbGetAppPlaylist: jest.fn(async () => fresh),
dbGetAppState: jest.fn(
async (key: string) => appState.get(key) ?? null
),
dbSetAppState: jest.fn(async (key: string, value: string) => {
appState.set(key, value);
}),
dbUpsertAppPlaylist: upsert,
dbUpsertAppPlaylists: jest.fn(),
dbRecoverLegacyPlaylists: jest.fn(),
};
testWindow.electron = undefined;
const service = createService();
Object.assign(service as object, {
storageBackend: {
kind: () => 'server',
documentStore: () => server,
},
});
return { service, server, appState };
}
it('are written per row on a fresh read, as conditional mutations', async () => {
const edited = { ...copied, title: 'Renamed elsewhere' };
const { service, server, appState } = serverService(edited);
await firstValueFrom(service.getAllPlaylists());
// Never the stale list copy, never an unconditional bulk write.
expect(server.dbUpsertAppPlaylists).not.toHaveBeenCalled();
expect(server.dbUpsertAppPlaylist).toHaveBeenCalledWith({
...edited,
isFullStalkerPortal: true,
});
expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe(
'1'
);
// The desktop-only steps stay with the desktop.
expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled();
});
await expect(
firstValueFrom(service.getAllPlaylists())
).resolves.toEqual([
expect.objectContaining({
_id: 'stalker-1',
isFullStalkerPortal: true,
}),
]);
it('stay unflagged for the next start after a conflict', async () => {
const { service, appState } = serverService(
copied,
jest.fn(async () => {
throw new PlaylistWriteConflict('stalker-1');
})
);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
expect(appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)).toBe(
'1'
);
// The desktop-only steps stay with the desktop.
expect(server.dbRecoverLegacyPlaylists).not.toHaveBeenCalled();
await firstValueFrom(service.getAllPlaylists());
expect(
appState.get(STALKER_PLAYLIST_METADATA_MIGRATION_FLAG)
).toBeUndefined();
});
});
it('does not rerun the SQLite Stalker metadata migration after the flag is set', async () => {
+14 -1
View File
@@ -304,7 +304,20 @@ export class PlaylistsService {
const updates =
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
if (updates.length > 0) {
if (this.storageBackend.kind() === 'server') {
// Shared with other devices: each row is read again and
// written on that version, never over a newer copy. A
// conflict leaves the flag unset, so the next start retries.
for (const { _id } of updates) {
const fresh = await store.dbGetAppPlaylist(_id);
const [update] = fresh
? this.collectStalkerMetadataMigrationUpdates([fresh])
: [];
if (update) {
await store.dbUpsertAppPlaylist(update);
}
}
} else if (updates.length > 0) {
await store.dbUpsertAppPlaylists(updates);
}
@@ -151,12 +151,19 @@ export function combineRecentViews(values: readonly unknown[]): unknown {
);
}
/** Items with a `viewedAt` (recently viewed), newest first; stable. */
export function newestViewsFirst<T>(items: readonly T[]): T[] {
/**
* Recently viewed items newest first, by `viewedAt` (Xtream maps) or
* `added_at` (playlist documents: an ISO date or epoch milliseconds);
* stable, undated items after dated ones.
*/
export function newestViewsFirst<T>(
items: readonly T[],
field: 'viewedAt' | 'added_at' = 'viewedAt'
): T[] {
const viewedAt = (item: unknown) => {
const at = isPlainObject(item)
? Date.parse(String(item['viewedAt']))
: NaN;
const value = isPlainObject(item) ? item[field] : undefined;
const at =
typeof value === 'number' ? value : Date.parse(String(value));
return Number.isNaN(at) ? 0 : at;
};
return [...items].sort((a, b) => viewedAt(b) - viewedAt(a));
@@ -300,6 +300,55 @@ describe('ServerMigrationService', () => {
});
});
it("keeps a document's newest view of an item both copies hold", async () => {
const view = (day: number, extra = {}) => ({
source: 'm3u',
id: 'b1',
url: 'http://s/1',
title: 'One',
category_id: 'live',
added_at: `2026-10-0${day}T20:00:00.000Z`,
...extra,
});
TestBed.overrideProvider(NgxIndexedDBService, {
useValue: {
getAll: () =>
of([
{
...browserPlaylists[0],
recentlyViewed: [
view(3, { poster_url: 'new.png' }),
],
playlist: {
items: [{ id: 'b1', url: 'http://s/1' }],
},
},
]),
},
});
session.documentStore.dbGetAppPlaylistMetas.mockResolvedValue([
{ _id: 'server-m', title: 'Family', url: browserPlaylists[0].url },
]);
const stored = {
_id: 'server-m',
title: 'Family',
url: browserPlaylists[0].url,
favorites: [],
// The same channel, watched there before this browser's view.
recentlyViewed: [view(1, { id: 's1' })],
playlist: { items: [{ id: 's1', url: 'http://s/1' }] },
};
session.documentStore.dbGetAppPlaylist.mockResolvedValue(stored);
await TestBed.inject(ServerMigrationService).moveBrowserData();
// Not skipped for an unchanged length: the newer view replaces it.
expect(session.documentStore.dbUpsertAppPlaylist).toHaveBeenCalledWith({
...stored,
recentlyViewed: [view(3, { id: 's1', poster_url: 'new.png' })],
});
});
it('keeps the move on offer when a playlist could not be uploaded', async () => {
session.documentStore.dbUpsertAppPlaylist
.mockRejectedValueOnce(new Error('413'))
@@ -12,6 +12,7 @@ import {
import {
combineRecentViews,
combineUserData,
newestViewsFirst,
remapChannelReferences,
retargetUserData,
unionItems,
@@ -178,18 +179,28 @@ export class ServerMigrationService {
serverChannels
)
),
// Newest first, one per item: its newest view (the server's on
// a tie), as the app keeps the list.
recentlyViewed: unionItems(
stored.recentlyViewed ?? [],
remapChannelReferences(
recentlyViewed,
browserChannels,
serverChannels
newestViewsFirst(
[
...(stored.recentlyViewed ?? []),
...remapChannelReferences(
recentlyViewed,
browserChannels,
serverChannels
),
],
'added_at'
)
),
};
// Also when only a newer view of an item it has changed.
if (
next.favorites.length !== (stored.favorites ?? []).length ||
next.recentlyViewed.length !== (stored.recentlyViewed ?? []).length
JSON.stringify(next.favorites) !==
JSON.stringify(stored.favorites ?? []) ||
JSON.stringify(next.recentlyViewed) !==
JSON.stringify(stored.recentlyViewed ?? [])
) {
await this.session.documentStore.dbUpsertAppPlaylist(next);
}