feat(realtime): доставлять клиентам события жизненного цикла альбома и изменения состава фото

Публиковать album.created/updated/deleted и изменения состава фото в личный поток
SSE, а также photo.created при загрузке, чтобы списки альбомов, счётчики и открытые
страницы альбома обновлялись без перезагрузки.

- members.Repository получил Audience (владелец + участники) для дешёвого веера
- albums.Service рассылает события в личные потоки получателей, перечитывая альбом
  через GetByID и attachCover, чтобы payload совпадал с GET /api/albums
- members.Service публикует при принятии инвайта, смене роли и исключении
- photos.Service публикует photo.created при загрузке и album.updated при
  добавлении/копировании/перемещении/восстановлении/корзине/окончательном удалении
- photos_count теперь считает фото вне корзины
- web-клиент подписывается на /api/sse/me один раз на уровне layout'а (app) и
  применяет события к стору альбомов; страница альбома уходит при потере доступа
This commit is contained in:
iu
2026-10-15 23:25:07 +03:00
parent cbff7a340c
commit e82f45647c
31 changed files with 2157 additions and 28 deletions
+144
View File
@@ -0,0 +1,144 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createAlbumLive, type AlbumLiveTarget } from './album-live';
import type { Album } from '$lib/types';
function album(id: string, title = id): Album {
return {
id,
owner_id: 'owner',
title,
description: '',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
};
}
// Minimal EventSource stand-in that records listeners and lets a test drive
// open/error/message without a browser.
class FakeEventSource {
static instances: FakeEventSource[] = [];
url: string;
closed = false;
onopen: ((ev: Event) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
private listeners = new Map<string, Array<(ev: MessageEvent) => void>>();
constructor(url: string) {
this.url = url;
FakeEventSource.instances.push(this);
}
addEventListener(type: string, cb: EventListenerOrEventListenerObject) {
const list = this.listeners.get(type) ?? [];
list.push(cb as (ev: MessageEvent) => void);
this.listeners.set(type, list);
}
close() {
this.closed = true;
}
emit(type: string, data: unknown) {
for (const cb of this.listeners.get(type) ?? []) {
cb({ data: JSON.stringify(data) } as MessageEvent);
}
}
open() {
this.onopen?.(new Event('open'));
}
fail() {
this.onerror?.(new Event('error'));
}
}
function target(): AlbumLiveTarget {
return { list: [], load: vi.fn(async () => {}) };
}
beforeEach(() => {
FakeEventSource.instances = [];
vi.stubGlobal('EventSource', FakeEventSource);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('personal album stream', () => {
it('opens one connection and applies album events to the list', () => {
const t = target();
const live = createAlbumLive(t);
live.start('tok');
expect(FakeEventSource.instances).toHaveLength(1);
const es = FakeEventSource.instances[0];
expect(es.url).toBe('/api/sse/me?token=tok');
es.emit('album.created', album('a'));
expect(t.list.map((a) => a.id)).toEqual(['a']);
es.emit('album.updated', { ...album('a'), title: 'A2' });
expect(t.list.map((a) => a.id)).toEqual(['a']);
expect(t.list[0].title).toBe('A2');
es.emit('album.deleted', { id: 'a' });
expect(t.list).toEqual([]);
});
it('keeps the list order when an album in the middle is updated', () => {
const t = target();
t.list = [album('a'), album('b'), album('c')];
const live = createAlbumLive(t);
live.start('tok');
FakeEventSource.instances[0].emit('album.updated', { ...album('b'), title: 'B2' });
expect(t.list.map((a) => a.id)).toEqual(['a', 'b', 'c']);
expect(t.list[1].title).toBe('B2');
});
it('re-syncs the list over REST after a reconnect, not on first open', () => {
const t = target();
const live = createAlbumLive(t);
live.start('tok');
FakeEventSource.instances[0].open();
expect(t.load).not.toHaveBeenCalled();
FakeEventSource.instances[0].fail();
vi.advanceTimersByTime(1000);
expect(FakeEventSource.instances).toHaveLength(2);
FakeEventSource.instances[1].open();
expect(t.load).toHaveBeenCalledTimes(1);
});
it('closes the connection on stop and does not reconnect afterwards', () => {
const t = target();
const live = createAlbumLive(t);
live.start('tok');
const es = FakeEventSource.instances[0];
live.stop();
expect(es.closed).toBe(true);
expect(live.connected).toBe(false);
es.fail();
vi.advanceTimersByTime(5000);
expect(FakeEventSource.instances).toHaveLength(1);
});
it('replacing the stream closes the previous connection (one per session)', () => {
const t = target();
const live = createAlbumLive(t);
live.start('first');
live.start('second');
expect(FakeEventSource.instances).toHaveLength(2);
expect(FakeEventSource.instances[0].closed).toBe(true);
expect(FakeEventSource.instances[1].url).toBe('/api/sse/me?token=second');
});
});
+54
View File
@@ -0,0 +1,54 @@
import { SSEClient, type SSEEvent } from '$lib/sse';
import { applyAlbumEvent } from './albums-events';
import type { Album } from '$lib/types';
// Target abstracts the album list and its REST reload so the personal stream
// can be tested without the Svelte runes store.
export interface AlbumLiveTarget {
list: Album[];
load: () => Promise<void>;
}
export interface AlbumLive {
// start opens the personal stream, replacing any previous connection so a
// session keeps exactly one subscription.
start(token: string): void;
// stop closes the stream and cancels reconnection.
stop(): void;
// handle is exposed for tests; the real client routes events through it.
handle(event: SSEEvent): void;
// connected reports whether a stream is currently open.
readonly connected: boolean;
}
export function createAlbumLive(target: AlbumLiveTarget): AlbumLive {
let client: SSEClient | null = null;
function handle(event: SSEEvent) {
target.list = applyAlbumEvent(target.list, event.type, event.data);
}
return {
start(token: string) {
this.stop();
client = new SSEClient({
path: '/api/sse/me',
onEvent: handle,
// After a reconnect, re-sync from REST to recover changes missed
// while the connection was down.
onReconnect: () => {
target.load().catch(() => {});
}
});
client.connect(token);
},
stop() {
client?.close();
client = null;
},
handle,
get connected() {
return client !== null;
}
};
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { applyAlbumEvent, removeAlbum, upsertAlbum, albumAccessLost } from './albums-events';
import type { Album } from '$lib/types';
function album(id: string, title = id): Album {
return {
id,
owner_id: 'owner',
title,
description: '',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z'
};
}
describe('upsertAlbum', () => {
it('prepends an unseen album', () => {
const list = [album('a')];
expect(upsertAlbum(list, album('b')).map((a) => a.id)).toEqual(['b', 'a']);
});
it('replaces an existing album in place, preserving order', () => {
const list = [album('a'), album('b'), album('c')];
const updated = upsertAlbum(list, { ...album('b'), title: 'B2' });
expect(updated.map((a) => a.id)).toEqual(['a', 'b', 'c']);
expect(updated[1].title).toBe('B2');
});
});
describe('removeAlbum', () => {
it('drops the album with the given id and keeps the rest', () => {
const list = [album('a'), album('b'), album('c')];
expect(removeAlbum(list, 'b').map((a) => a.id)).toEqual(['a', 'c']);
});
});
describe('albumAccessLost', () => {
it('is true once the album is gone from the list', () => {
const list = [album('a'), album('b')];
expect(albumAccessLost(list, 'a')).toBe(false);
expect(albumAccessLost(removeAlbum(list, 'a'), 'a')).toBe(true);
});
});
describe('applyAlbumEvent', () => {
it('adds an album on album.created', () => {
expect(applyAlbumEvent([], 'album.created', album('a')).map((a) => a.id)).toEqual(['a']);
});
it('replaces an album in place on album.updated', () => {
const list = [album('a'), album('b')];
const next = applyAlbumEvent(list, 'album.updated', { ...album('a'), title: 'A2' });
expect(next.map((a) => a.id)).toEqual(['a', 'b']);
expect(next[0].title).toBe('A2');
});
it('removes an album on album.deleted carrying only an id', () => {
const list = [album('a'), album('b')];
expect(applyAlbumEvent(list, 'album.deleted', { id: 'a' }).map((a) => a.id)).toEqual(['b']);
});
it('ignores events it does not handle', () => {
const list = [album('a')];
expect(applyAlbumEvent(list, 'notification', { id: 'x' })).toBe(list);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { Album } from '$lib/types';
// Pure list mutations for the album store. They are kept free of runes so the
// personal-stream handling can be unit-tested without mounting a component.
// upsertAlbum inserts a new album at the head of the list or replaces an
// existing one in place, so receiving an update never reorders the list.
export function upsertAlbum(list: Album[], album: Album): Album[] {
const i = list.findIndex((a) => a.id === album.id);
if (i === -1) {
return [album, ...list];
}
return list.map((a) => (a.id === album.id ? album : a));
}
export function removeAlbum(list: Album[], id: string): Album[] {
return list.filter((a) => a.id !== id);
}
// albumAccessLost reports whether an album is no longer in the user's list,
// which is the signal for the open album page to leave to the overview.
export function albumAccessLost(list: Album[], albumId: string): boolean {
return !list.some((a) => a.id === albumId);
}
// applyAlbumEvent applies a personal-stream event to the album list: an album
// appeared or changed, or it is gone (deleted or access revoked). Unknown event
// types leave the list untouched.
export function applyAlbumEvent(list: Album[], type: string, data: unknown): Album[] {
switch (type) {
case 'album.created':
case 'album.updated':
return upsertAlbum(list, data as Album);
case 'album.deleted':
return removeAlbum(list, (data as { id: string }).id);
default:
return list;
}
}
+1
View File
@@ -24,6 +24,7 @@ export class SSEClient {
'member.joined',
'member.left',
'member.updated',
'album.created',
'album.updated',
'album.deleted',
'notification'
+14
View File
@@ -0,0 +1,14 @@
import { albums } from './albums.svelte';
import { createAlbumLive } from '$lib/album-live';
// Single personal-stream subscription for the (app) layout. It is a module
// singleton so navigation between pages reuses the same connection.
export const albumLive = createAlbumLive({
get list() {
return albums.list;
},
set list(next) {
albums.list = next;
},
load: () => albums.load()
});
+3 -7
View File
@@ -1,4 +1,5 @@
import { api } from '$lib/api';
import { upsertAlbum, removeAlbum } from '$lib/albums-events';
import type { Album } from '$lib/types';
import { auth } from './auth.svelte';
@@ -55,16 +56,11 @@ class AlbumsStore {
}
upsert(album: Album) {
const i = this.list.findIndex((a) => a.id === album.id);
if (i === -1) {
this.list = [album, ...this.list];
} else {
this.list = this.list.map((a) => (a.id === album.id ? album : a));
}
this.list = upsertAlbum(this.list, album);
}
remove(id: string) {
this.list = this.list.filter((a) => a.id !== id);
this.list = removeAlbum(this.list, id);
}
reset() {
+12
View File
@@ -5,10 +5,22 @@
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
import TransferDialog from '$lib/components/TransferDialog.svelte';
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albumLive } from '$lib/stores/album-live';
let { children } = $props();
const showPanel = $derived($page.url.pathname !== '/');
// Personal stream: one subscription for the whole session, kept alive across
// navigation inside the (app) group. The stream updates the album list
// directly from event payloads and re-syncs it after a reconnect.
$effect(() => {
const token = auth.access;
if (!token) return;
albumLive.start(token);
return () => albumLive.stop();
});
</script>
<AppShell panelVisible={showPanel}>
@@ -10,6 +10,7 @@
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { albumAccessLost } from '$lib/albums-events';
import { density } from '$lib/stores/density.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { photoUpdates } from '$lib/stores/photo-updates.svelte';
@@ -82,6 +83,15 @@
});
});
$effect(() => {
// The personal stream removes albums from the store on deletion or access
// loss (member excluded); when the album open here disappears, leave to
// the overview instead of showing a stale page.
if (!albums.loaded || loading || !album) return;
if (!albumAccessLost(albums.list, albumId)) return;
goto('/');
});
$effect(() => {
// Patch a photo edited in the panel (e.g. tags) into the grid list so
// derived UI like the 360 badge updates without a reload. Read the list