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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export class SSEClient {
|
||||
'member.joined',
|
||||
'member.left',
|
||||
'member.updated',
|
||||
'album.created',
|
||||
'album.updated',
|
||||
'album.deleted',
|
||||
'notification'
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user