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
+365 -1
View File
@@ -16,7 +16,8 @@
"svelte-check": "^4.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vitest": "^2.1.9"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -1226,6 +1227,112 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
@@ -1247,6 +1354,15 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1256,6 +1372,40 @@
"node": ">= 0.4"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"engines": {
"node": ">=8"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"engines": {
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1306,6 +1456,15 @@
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -1343,6 +1502,12 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1404,6 +1569,24 @@
}
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1723,6 +1906,12 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1774,6 +1963,21 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1884,6 +2088,12 @@
"integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==",
"dev": true
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -1907,6 +2117,18 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/svelte": {
"version": "5.57.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz",
@@ -1976,6 +2198,45 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -2057,6 +2318,28 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
@@ -2076,6 +2359,87 @@
}
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zimmerframe": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
+4 -2
View File
@@ -7,7 +7,8 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
@@ -18,6 +19,7 @@
"svelte-check": "^4.1.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^5.4.0"
"vite": "^5.4.0",
"vitest": "^2.1.9"
}
}
+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
+17
View File
@@ -0,0 +1,17 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
// Frontend tests cover the transport-agnostic logic (event application, stream
// lifecycle). They run on plain TypeScript modules, so no Svelte plugin or DOM
// environment is needed.
export default defineConfig({
resolve: {
alias: {
$lib: fileURLToPath(new URL('./src/lib', import.meta.url))
}
},
test: {
include: ['src/**/*.test.ts'],
environment: 'node'
}
});