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:
Generated
+365
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user