From 377b4b628c95ad9d08f702b6742584e5a624cb6c Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 22 Sep 2026 13:04:58 +0000 Subject: [PATCH] Plain-record library snapshot and change subscription (closes #43) Project the decrypted store into plain, key-free records the GUI reads across Electron IPC: PhotoRecord, AlbumRecord, LibrarySnapshot, and the LibraryChange a subscription delivers. snapshot() is synchronous (RAM only): one PhotoRecord per fileID, deduped across memberships with every album id, newest first. subscribe({ onChange }) fires a LibraryChange (changed full records plus removed id sets) only when a refresh alters the projection; a no-op refresh fires nothing. Magic-metadata field names are taken from the repo fixtures: w/h and visibility. Edited-name/edited-time precedence is pubMagicMetadata over basic metadata; takenAt is exposed in milliseconds (Ente stores microseconds). Model: opus-4-8 --- src/index.ts | 6 + src/library/index.ts | 70 ++++++++ src/library/records.ts | 254 ++++++++++++++++++++++++++++ test/library/records.test.ts | 279 +++++++++++++++++++++++++++++++ test/library/snapshot.test.ts | 303 ++++++++++++++++++++++++++++++++++ 5 files changed, 912 insertions(+) create mode 100644 src/library/records.ts create mode 100644 test/library/records.test.ts create mode 100644 test/library/snapshot.test.ts diff --git a/src/index.ts b/src/index.ts index 06c4bca..73e5439 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,12 @@ export { type RefreshEvent, type RefreshProgressCallback, } from "./library/index.js"; +export type { + AlbumRecord, + PhotoRecord, + LibrarySnapshot, + LibraryChange, +} from "./library/records.js"; export { decryptCollection, decryptFile } from "./model/index.js"; export { downloadFile, downloadThumbnail } from "./download/index.js"; export type { diff --git a/src/library/index.ts b/src/library/index.ts index ff349e9..4d06e87 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -23,6 +23,14 @@ import { join } from "node:path"; import envPaths from "env-paths"; import { MetadataStore } from "./store.js"; +import { + deriveRecords, + snapshotFrom, + diffRecords, + type DerivedRecords, + type LibrarySnapshot, + type LibraryChange, +} from "./records.js"; import type { CollectionsPage, FilesPage } from "../client.js"; import type { Collection, EnteFile } from "../model/types.js"; @@ -91,6 +99,12 @@ export class Library { private closed = false; private lastRefreshAt?: number; private lastError?: string; + // The plain-record projection as of the last refresh, and the GUI change + // subscribers. A refresh that alters the projection notifies each with the + // delta; `lastRecords` is kept current every refresh so a subscriber that + // joins later diffs against the state its own `snapshot()` already returned. + private readonly subscribers = new Set<(change: LibraryChange) => void>(); + private lastRecords: DerivedRecords; // RAM holds changes disk has not yet accepted (an earlier save failed). // Cleared only when a save actually succeeds; keeps the store trying to // persist and the failure visible in `status()` until then. @@ -112,6 +126,7 @@ export class Library { this.downloadDirectory = args.downloadDirectory; this.intervalMs = args.intervalMs; this.onProgress = args.onProgress; + this.lastRecords = this.deriveNow(); } // Load the cache and start the refresh loop. With an empty cache the first @@ -172,6 +187,28 @@ export class Library { return this.store.getFile(collectionID, fileID); } + // A synchronous, RAM-only projection of the whole library into plain + // records (no keys), the surface the GUI reads across IPC. Photos are + // deduplicated to one record per file and ordered newest first. + snapshot(): LibrarySnapshot { + return snapshotFrom(this.deriveNow(), Date.now()); + } + + // Deliver a `LibraryChange` whenever a refresh alters the projection. A + // refresh that changes nothing delivers nothing. The returned handle's + // `unsubscribe` stops delivery. + subscribe(args: { onChange: (change: LibraryChange) => void }): { + unsubscribe: () => void; + } { + const { onChange } = args; + this.subscribers.add(onChange); + return { + unsubscribe: () => { + this.subscribers.delete(onChange); + }, + }; + } + status(): LibraryStatus { let files = 0; const collections = this.store.listCollections(); @@ -292,6 +329,20 @@ export class Library { if (changed) this.unsaved = true; + // Reproject and notify subscribers of the delta. This tracks RAM (what + // reads see), so it fires whether or not the save below succeeds; a + // save failure surfaces separately through `status().lastError`. + // `lastRecords` advances every changed refresh so the next diff is + // against current state. + if (changed) { + const next = this.deriveNow(); + if (this.subscribers.size > 0) { + const change = diffRecords(this.lastRecords, next, Date.now()); + if (change) this.notify(change); + } + this.lastRecords = next; + } + // Persist whenever RAM holds changes disk has not accepted — including // changes an earlier cycle staged whose save failed. `unsaved` clears // only once a save lands, so a save failure both stays visible through @@ -304,6 +355,25 @@ export class Library { } } + // Gather every file membership and project the store into by-id records. + private deriveNow(): DerivedRecords { + const collections = this.store.listCollections(); + const files: EnteFile[] = []; + for (const c of collections) files.push(...this.store.listFiles(c.id)); + return deriveRecords(collections, files); + } + + private notify(change: LibraryChange): void { + for (const onChange of this.subscribers) { + // A misbehaving subscriber must not break the loop or its peers. + try { + onChange(change); + } catch { + // ignore + } + } + } + private emit(event: RefreshEvent): void { if (!this.onProgress) return; // A misbehaving callback must not break the refresh loop. diff --git a/src/library/records.ts b/src/library/records.ts new file mode 100644 index 0000000..85d452a --- /dev/null +++ b/src/library/records.ts @@ -0,0 +1,254 @@ +// Plain records projected from the decrypted store, and the diff between two +// projections. These are the library's GUI-facing surface: they hold no key +// material and no binary, so they survive `structuredClone`/JSON across the +// Electron IPC boundary where methods and file keys cannot go (design #36, +// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in +// the main process; the window only ever sees these records. +// +// Ente holds edited/basic times in microseconds; records expose `takenAt` in +// milliseconds. The magic-metadata field names below are the ones the Ente +// clients write, confirmed against the repo's own fixtures: `w`/`h` in +// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts. + +import type { + Collection, + CollectionType, + EnteFile, + FileType, +} from "../model/types.js"; + +// Ente private-magic-metadata visibility values. +const VISIBILITY_ARCHIVED = 1; +const VISIBILITY_HIDDEN = 2; + +// A single photo, deduplicated across the collections it belongs to. No key, +// no binary: safe to send to a window. +export interface PhotoRecord { + fileID: number; + // Every collection this file is a member of, ascending. + albumIDs: number[]; + // `pubMagicMetadata.editedName` when the user renamed the file, else the + // basic-metadata title. + title: string; + // Milliseconds. `pubMagicMetadata.editedTime` when the user edited the + // date, else basic-metadata `creationTime`. + takenAt: number; + fileType: FileType; + caption?: string; + width?: number; + height?: number; + latitude?: number; + longitude?: number; + isArchived: boolean; + isHidden: boolean; + // Local cache paths, set once a later phase caches the bytes; unset here. + thumbnailPath?: string; + originalPath?: string; +} + +export interface AlbumRecord { + collectionID: number; + name: string; + // `favorites` identifies the account's favorites album. + type: CollectionType; + isShared: boolean; + updationTime: number; + // The album's files, newest first. + fileIDs: number[]; +} + +export interface LibrarySnapshot { + albums: AlbumRecord[]; + photos: PhotoRecord[]; + // Wall-clock milliseconds when the snapshot was taken. + takenAt: number; +} + +export interface LibraryChange { + // Full records for albums/photos added or changed by the refresh. + albumsChanged: AlbumRecord[]; + photosChanged: PhotoRecord[]; + fileIDsRemoved: number[]; + albumIDsRemoved: number[]; + // Wall-clock milliseconds of the refresh that produced this change. + refreshedAt: number; +} + +// The by-id projection of the store at one moment; the source for both +// `snapshotFrom` (sorted arrays for the GUI) and `diffRecords` (change sets). +export interface DerivedRecords { + albums: Map; + photos: Map; +} + +const asString = (v: unknown): string | undefined => + typeof v === "string" && v.length > 0 ? v : undefined; + +const asNumber = (v: unknown): number | undefined => + typeof v === "number" && Number.isFinite(v) ? v : undefined; + +const microsToMillis = (micros: number): number => Math.floor(micros / 1000); + +// Newest first, with fileID as a stable tiebreak so equal-timed files order +// deterministically. +const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number => + b.takenAt - a.takenAt || b.fileID - a.fileID; + +// Build one PhotoRecord from every membership of a file. The memberships share +// the same underlying file, so metadata is read from a single representative +// (the most recently synced, lowest collection id to break ties); `albumIDs` +// gathers them all. +const toPhotoRecord = ( + fileID: number, + memberships: EnteFile[], +): PhotoRecord => { + const albumIDs = memberships + .map((m) => m.collectionID) + .sort((a, b) => a - b); + const rep = memberships.reduce((best, m) => + m.updationTime > best.updationTime || + (m.updationTime === best.updationTime && + m.collectionID < best.collectionID) + ? m + : best, + ); + + const pub = rep.pubMagicMetadata ?? {}; + const priv = rep.magicMetadata ?? {}; + + const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime; + const visibility = asNumber(priv.visibility); + + const record: PhotoRecord = { + fileID, + albumIDs, + title: asString(pub.editedName) ?? rep.metadata.title, + takenAt: microsToMillis(takenAtMicros), + fileType: rep.metadata.fileType, + isArchived: visibility === VISIBILITY_ARCHIVED, + isHidden: visibility === VISIBILITY_HIDDEN, + }; + + const caption = asString(pub.caption); + if (caption !== undefined) record.caption = caption; + const width = asNumber(pub.w); + if (width !== undefined) record.width = width; + const height = asNumber(pub.h); + if (height !== undefined) record.height = height; + if (rep.metadata.latitude !== undefined) + record.latitude = rep.metadata.latitude; + if (rep.metadata.longitude !== undefined) + record.longitude = rep.metadata.longitude; + + return record; +}; + +const toAlbumRecord = ( + collection: Collection, + files: EnteFile[], + takenAtByFile: Map, +): AlbumRecord => { + const fileIDs = files + .filter((f) => f.collectionID === collection.id) + .map((f) => f.id) + .sort( + (a, b) => + (takenAtByFile.get(b) ?? 0) - (takenAtByFile.get(a) ?? 0) || + b - a, + ); + return { + collectionID: collection.id, + name: collection.name, + type: collection.type, + isShared: collection.isShared, + updationTime: collection.updationTime, + fileIDs, + }; +}; + +// Project the decrypted collections and file memberships into by-id records. +// `files` is every membership (a file appears once per collection it is in). +export const deriveRecords = ( + collections: Collection[], + files: EnteFile[], +): DerivedRecords => { + const byFileID = new Map(); + for (const f of files) { + const arr = byFileID.get(f.id); + if (arr) arr.push(f); + else byFileID.set(f.id, [f]); + } + + const photos = new Map(); + const takenAtByFile = new Map(); + for (const [fileID, memberships] of byFileID) { + const record = toPhotoRecord(fileID, memberships); + photos.set(fileID, record); + takenAtByFile.set(fileID, record.takenAt); + } + + const albums = new Map(); + for (const c of collections) { + albums.set(c.id, toAlbumRecord(c, files, takenAtByFile)); + } + + return { albums, photos }; +}; + +// Sorted, GUI-ready arrays: albums newest updated first, photos newest first. +export const snapshotFrom = ( + records: DerivedRecords, + takenAt: number, +): LibrarySnapshot => ({ + albums: [...records.albums.values()].sort( + (a, b) => + b.updationTime - a.updationTime || b.collectionID - a.collectionID, + ), + photos: [...records.photos.values()].sort(byNewestPhoto), + takenAt, +}); + +// Records compare by value; they are plain and built with a fixed key order, so +// a serialized form is a sound equality key. +const same = (a: unknown, b: unknown): boolean => + JSON.stringify(a) === JSON.stringify(b); + +const diffMap = ( + prev: Map, + next: Map, +): { changed: T[]; removed: number[] } => { + const changed: T[] = []; + for (const [id, record] of next) { + const before = prev.get(id); + if (before === undefined || !same(before, record)) changed.push(record); + } + const removed: number[] = []; + for (const id of prev.keys()) if (!next.has(id)) removed.push(id); + removed.sort((a, b) => a - b); + return { changed, removed }; +}; + +// The change between two projections, or undefined when nothing changed. +export const diffRecords = ( + prev: DerivedRecords, + next: DerivedRecords, + refreshedAt: number, +): LibraryChange | undefined => { + const albums = diffMap(prev.albums, next.albums); + const photos = diffMap(prev.photos, next.photos); + if ( + albums.changed.length === 0 && + albums.removed.length === 0 && + photos.changed.length === 0 && + photos.removed.length === 0 + ) { + return undefined; + } + return { + albumsChanged: albums.changed, + photosChanged: photos.changed, + fileIDsRemoved: photos.removed, + albumIDsRemoved: albums.removed, + refreshedAt, + }; +}; diff --git a/test/library/records.test.ts b/test/library/records.test.ts new file mode 100644 index 0000000..cf4acd3 --- /dev/null +++ b/test/library/records.test.ts @@ -0,0 +1,279 @@ +/** + * Tests for the plain-record mapping in `src/library/records.ts` (issue #43). + * + * The library keeps decrypted `Collection`/`EnteFile` objects in RAM, but those + * carry binary keys and cannot cross the Electron IPC boundary. `deriveRecords` + * projects them into plain `AlbumRecord`/`PhotoRecord` values — no keys, no + * `Uint8Array`, JSON-safe — that the GUI process consumes. This file pins: + * + * 1. Field mapping from `metadata` and the two magic-metadata layers, using the + * real Ente field names confirmed against the fixtures in + * `test/cli/metadata-backup.test.ts` (`w`/`h`) and `test/library/store.test.ts` + * (`visibility`): title/takenAt precedence, caption, width/height, geo, + * visibility → isArchived/isHidden, fileType. + * 2. `takenAt` is milliseconds; Ente stores creationTime/editedTime in + * microseconds, so the record divides by 1000. + * 3. Deduplication: one `PhotoRecord` per fileID even when the file belongs to + * several collections, with every membership's collection id in `albumIDs`. + * 4. Ordering: photos and album `fileIDs` are newest first. + * 5. No key material survives the projection. + * 6. `diffRecords` reports exactly what changed between two derivations, and + * returns undefined when nothing changed. + */ + +import { describe, it, expect } from "vitest"; +import { + deriveRecords, + snapshotFrom, + diffRecords, +} from "../../src/library/records.js"; +import type { Collection, EnteFile } from "../../src/model/types.js"; + +const OWNER = 42; + +// Microsecond epoch values, as Ente stores times. 1e15 ≈ 2001 in microseconds. +const T = (micros: number): number => micros; + +const collection = ( + id: number, + opts: Partial = {}, +): Collection => ({ + id, + ownerID: OWNER, + key: new Uint8Array([id & 0xff, 1, 2, 3]), + name: `album-${id}`, + type: "album", + updationTime: T(1_700_000_000_000_000), + isShared: false, + ...opts, +}); + +const file = ( + id: number, + collectionID: number, + opts: Partial & { + creationTime?: number; + title?: string; + } = {}, +): EnteFile => { + const { creationTime, title, ...rest } = opts; + return { + id, + collectionID, + ownerID: OWNER, + key: new Uint8Array([id & 0xff, 9, 8, 7]), + metadata: { + title: title ?? `file-${id}.jpg`, + fileType: "image", + creationTime: creationTime ?? T(1_700_000_000_000_000), + modificationTime: T(1_700_000_000_000_000), + }, + file: { decryptionHeader: "aGVhZGVy" }, + thumbnail: { decryptionHeader: "dGh1bWI=" }, + updationTime: T(1_700_000_000_000_000), + ...rest, + }; +}; + +describe("deriveRecords: photo mapping", () => { + it("projects a file into a plain PhotoRecord with no key material", () => { + const f = file(1001, 1, { + metadata: { + title: "IMG_1.jpg", + fileType: "image", + creationTime: T(1_699_000_000_000_000), + modificationTime: T(1_699_000_000_000_000), + latitude: 52.52, + longitude: 13.405, + }, + }); + const { photos } = deriveRecords([collection(1)], [f]); + const rec = photos.get(1001)!; + + expect(rec.fileID).toBe(1001); + expect(rec.albumIDs).toEqual([1]); + expect(rec.title).toBe("IMG_1.jpg"); + expect(rec.fileType).toBe("image"); + expect(rec.latitude).toBeCloseTo(52.52); + expect(rec.longitude).toBeCloseTo(13.405); + expect(rec.isArchived).toBe(false); + expect(rec.isHidden).toBe(false); + + // Safe to send over IPC: no key, no Uint8Array, JSON round-trips whole. + expect("key" in rec).toBe(false); + expect(JSON.parse(JSON.stringify(rec))).toEqual(rec); + }); + + it("takenAt is creationTime converted from microseconds to milliseconds", () => { + const f = file(1001, 1, { creationTime: T(1_699_000_000_000_000) }); + const { photos } = deriveRecords([collection(1)], [f]); + expect(photos.get(1001)!.takenAt).toBe(1_699_000_000_000); + }); + + it("prefers pubMagicMetadata.editedName and editedTime over metadata", () => { + const f = file(1001, 1, { + title: "original.jpg", + creationTime: T(1_699_000_000_000_000), + pubMagicMetadata: { + editedName: "Sunset over the bay", + editedTime: T(1_650_000_000_000_000), + }, + }); + const { photos } = deriveRecords([collection(1)], [f]); + const rec = photos.get(1001)!; + expect(rec.title).toBe("Sunset over the bay"); + expect(rec.takenAt).toBe(1_650_000_000_000); + }); + + it("falls back to metadata when the edited fields are empty or absent", () => { + const f = file(1001, 1, { + title: "original.jpg", + creationTime: T(1_699_000_000_000_000), + pubMagicMetadata: { editedName: "" }, + }); + const { photos } = deriveRecords([collection(1)], [f]); + const rec = photos.get(1001)!; + expect(rec.title).toBe("original.jpg"); + expect(rec.takenAt).toBe(1_699_000_000_000); + }); + + it("maps caption and width/height from the public magic metadata", () => { + const f = file(1001, 1, { + pubMagicMetadata: { + caption: "at the beach", + w: 3000, + h: 2000, + }, + }); + const rec = deriveRecords([collection(1)], [f]).photos.get(1001)!; + expect(rec.caption).toBe("at the beach"); + expect(rec.width).toBe(3000); + expect(rec.height).toBe(2000); + }); + + it("omits optional fields that are absent from the metadata", () => { + const rec = deriveRecords([collection(1)], [file(1001, 1)]).photos.get( + 1001, + )!; + expect("caption" in rec).toBe(false); + expect("width" in rec).toBe(false); + expect("height" in rec).toBe(false); + expect("latitude" in rec).toBe(false); + }); + + it("reads archived and hidden from private magicMetadata.visibility", () => { + const archived = file(1, 1, { magicMetadata: { visibility: 1 } }); + const hidden = file(2, 1, { magicMetadata: { visibility: 2 } }); + const visible = file(3, 1, { magicMetadata: { visibility: 0 } }); + const { photos } = deriveRecords( + [collection(1)], + [archived, hidden, visible], + ); + expect(photos.get(1)).toMatchObject({ + isArchived: true, + isHidden: false, + }); + expect(photos.get(2)).toMatchObject({ + isArchived: false, + isHidden: true, + }); + expect(photos.get(3)).toMatchObject({ + isArchived: false, + isHidden: false, + }); + }); +}); + +describe("deriveRecords: dedup and ordering", () => { + it("emits one PhotoRecord per fileID across memberships, all albums listed", () => { + // File 1001 belongs to collections 1 and 2; 2002 only to 2. + const files = [ + file(1001, 1, { creationTime: T(1_700_000_000_000_000) }), + file(1001, 2, { creationTime: T(1_700_000_000_000_000) }), + file(2002, 2, { creationTime: T(1_710_000_000_000_000) }), + ]; + const { photos } = deriveRecords([collection(1), collection(2)], files); + expect([...photos.keys()].sort((a, b) => a - b)).toEqual([1001, 2002]); + expect(photos.get(1001)!.albumIDs).toEqual([1, 2]); + expect(photos.get(2002)!.albumIDs).toEqual([2]); + }); + + it("orders snapshot photos newest first by takenAt", () => { + const files = [ + file(1, 1, { creationTime: T(1_600_000_000_000_000) }), + file(2, 1, { creationTime: T(1_800_000_000_000_000) }), + file(3, 1, { creationTime: T(1_700_000_000_000_000) }), + ]; + const snap = snapshotFrom(deriveRecords([collection(1)], files), 123); + expect(snap.photos.map((p) => p.fileID)).toEqual([2, 3, 1]); + expect(snap.takenAt).toBe(123); + }); + + it("orders album fileIDs newest first", () => { + const files = [ + file(1, 7, { creationTime: T(1_600_000_000_000_000) }), + file(2, 7, { creationTime: T(1_800_000_000_000_000) }), + file(3, 7, { creationTime: T(1_700_000_000_000_000) }), + ]; + const { albums } = deriveRecords([collection(7)], files); + expect(albums.get(7)!.fileIDs).toEqual([2, 3, 1]); + }); +}); + +describe("deriveRecords: album mapping", () => { + it("carries collection identity, sharing, and the favorites type", () => { + const fav = collection(9, { + name: "Favorites", + type: "favorites", + isShared: true, + updationTime: T(1_705_000_000_000_000), + }); + const rec = deriveRecords([fav], [file(1, 9)]).albums.get(9)!; + expect(rec).toMatchObject({ + collectionID: 9, + name: "Favorites", + type: "favorites", + isShared: true, + updationTime: T(1_705_000_000_000_000), + }); + expect("key" in rec).toBe(false); + expect(JSON.parse(JSON.stringify(rec))).toEqual(rec); + }); +}); + +describe("diffRecords", () => { + const at = 999; + + it("returns undefined when nothing changed", () => { + const a = deriveRecords([collection(1)], [file(1, 1)]); + const b = deriveRecords([collection(1)], [file(1, 1)]); + expect(diffRecords(a, b, at)).toBeUndefined(); + }); + + it("reports added and changed albums and photos and removals", () => { + const before = deriveRecords( + [collection(1), collection(2)], + [file(1, 1), file(2, 2)], + ); + // Collection 2 is gone (album + its only file removed). Collection 1 is + // renamed (changed album), gains file 3, and file 1 is retitled. + const after = deriveRecords( + [collection(1, { name: "renamed" })], + [ + file(1, 1, { + pubMagicMetadata: { editedName: "new title" }, + }), + file(3, 1), + ], + ); + const change = diffRecords(before, after, at)!; + expect(change.refreshedAt).toBe(at); + expect(change.albumIDsRemoved).toEqual([2]); + expect(change.fileIDsRemoved).toEqual([2]); + expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([1]); + expect(change.albumsChanged[0]!.name).toBe("renamed"); + expect( + change.photosChanged.map((p) => p.fileID).sort((x, y) => x - y), + ).toEqual([1, 3]); + }); +}); diff --git a/test/library/snapshot.test.ts b/test/library/snapshot.test.ts new file mode 100644 index 0000000..511b4c4 --- /dev/null +++ b/test/library/snapshot.test.ts @@ -0,0 +1,303 @@ +/** + * Tests for `Library.snapshot()` and `Library.subscribe()` (issue #43). + * + * These are the surface the GUI consumes across Electron IPC. `snapshot()` is + * synchronous — it reads the in-RAM store and projects it into plain records + * (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever + * a background refresh actually changes the derived records. The contracts: + * + * 1. `snapshot()` deduplicates a file across memberships into one record with + * every album id, orders photos newest first, and carries no key material. + * 2. `subscribe` fires on a refresh that changes something, with the exact + * changed and removed sets for both albums and photos. + * 3. A refresh that changes nothing (an empty diff) fires no change. + * 4. `unsubscribe()` stops further delivery. + * + * The client is the same scripted mock used by the refresh-loop tests: no + * crypto, no network. Interval tests use a short real interval and `vi.waitFor`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { Library } from "../../src/library/index.js"; +import type { LibraryChange } from "../../src/library/records.js"; +import type { CollectionsPage, FilesPage } from "../../src/client.js"; +import type { Collection, EnteFile } from "../../src/model/types.js"; + +const USER_ID = 42; +const FAST_INTERVAL = 0.02; + +const collection = ( + id: number, + updationTime: number, + name = `album-${id}`, +): Collection => ({ + id, + ownerID: USER_ID, + key: new Uint8Array([id & 0xff]), + name, + type: "album", + updationTime, + isShared: false, +}); + +const file = ( + id: number, + collectionID: number, + creationTime: number, +): EnteFile => ({ + id, + collectionID, + ownerID: USER_ID, + key: new Uint8Array([id & 0xff]), + metadata: { + title: `file-${id}.jpg`, + fileType: "image", + creationTime, + modificationTime: creationTime, + }, + file: { decryptionHeader: "aGVhZGVy" }, + thumbnail: { decryptionHeader: "dGh1bWI=" }, + updationTime: creationTime, +}); + +class MockClient { + userID = USER_ID; + collectionsQueue: CollectionsPage[] = []; + filesByCollection = new Map(); + + whoami(): { email: string; userID: number } { + return { email: "user@example.com", userID: this.userID }; + } + + async collectionsSince(args: { + sinceTime: number; + }): Promise { + return ( + this.collectionsQueue.shift() ?? { + collections: [], + deleted: [], + cursor: args.sinceTime, + } + ); + } + + async filesSince(args: { + collectionID: number; + collectionKey: Uint8Array; + sinceTime: number; + }): Promise { + const queue = this.filesByCollection.get(args.collectionID); + return ( + queue?.shift() ?? { + files: [], + deleted: [], + cursor: args.sinceTime, + } + ); + } + + filesFor(collectionID: number, ...pages: FilesPage[]): void { + this.filesByCollection.set(collectionID, pages); + } +} + +describe("Library.snapshot and Library.subscribe", () => { + let dir: string; + let cacheDirectory: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "quak-snapshot-")); + cacheDirectory = join(dir, "cache"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("snapshot() dedupes across memberships, orders newest first, holds no keys", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100), collection(2, 100)], + deleted: [], + cursor: 100, + }); + // File 1001 is in both collections; 2002 only in collection 2 and newer. + client.filesFor(1, { + files: [file(1001, 1, 1_600_000_000_000_000)], + deleted: [], + cursor: 1_600_000_000_000_000, + }); + client.filesFor(2, { + files: [ + file(1001, 2, 1_600_000_000_000_000), + file(2002, 2, 1_800_000_000_000_000), + ], + deleted: [], + cursor: 1_800_000_000_000_000, + }); + + const lib = await Library.open({ client, cacheDirectory }); + try { + const snap = lib.snapshot(); + + // One record per fileID, newest first, both albums on the shared file. + expect(snap.photos.map((p) => p.fileID)).toEqual([2002, 1001]); + const shared = snap.photos.find((p) => p.fileID === 1001)!; + expect(shared.albumIDs).toEqual([1, 2]); + expect(shared.takenAt).toBe(1_600_000_000_000); + + expect(snap.albums.map((a) => a.collectionID).sort()).toEqual([ + 1, 2, + ]); + + // Nothing carries key material; the whole snapshot is JSON-safe. + expect(JSON.parse(JSON.stringify(snap))).toEqual(snap); + for (const p of snap.photos) expect("key" in p).toBe(false); + for (const a of snap.albums) expect("key" in a).toBe(false); + } finally { + lib.close(); + } + }); + + it("subscribe fires on a refresh change with the correct changed/removed sets", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100), collection(2, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 1_600_000_000_000_000)], + deleted: [], + cursor: 1_600_000_000_000_000, + }); + client.filesFor(2, { + files: [file(2002, 2, 1_600_000_000_000_000)], + deleted: [], + cursor: 1_600_000_000_000_000, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + }); + const changes: LibraryChange[] = []; + const { unsubscribe } = lib.subscribe({ + onChange: (c) => changes.push(c), + }); + try { + // Next refresh: collection 2 (and its file) tombstoned; collection 1 + // gains file 1003. + client.filesFor(1, { + files: [file(1003, 1, 1_700_000_000_000_000)], + deleted: [], + cursor: 1_700_000_000_000_000, + }); + client.collectionsQueue.push({ + collections: [collection(1, 200)], + deleted: [2], + cursor: 200, + }); + + await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0), { + timeout: 2000, + interval: 5, + }); + + const change = changes[0]!; + expect(change.albumIDsRemoved).toEqual([2]); + expect(change.fileIDsRemoved).toEqual([2002]); + expect(change.photosChanged.map((p) => p.fileID)).toEqual([1003]); + expect(change.albumsChanged.map((a) => a.collectionID)).toEqual([ + 1, + ]); + expect(change.refreshedAt).toBeGreaterThan(0); + } finally { + unsubscribe(); + lib.close(); + } + }); + + it("a refresh that changes nothing fires no change", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 1_600_000_000_000_000)], + deleted: [], + cursor: 1_600_000_000_000_000, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + }); + const changes: LibraryChange[] = []; + const { unsubscribe } = lib.subscribe({ + onChange: (c) => changes.push(c), + }); + try { + // Let several empty-diff ticks pass; none may deliver a change. + await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 6)); + expect(changes).toEqual([]); + } finally { + unsubscribe(); + lib.close(); + } + }); + + it("unsubscribe stops further delivery", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 1_600_000_000_000_000)], + deleted: [], + cursor: 1_600_000_000_000_000, + }); + + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: FAST_INTERVAL, + }); + const changes: LibraryChange[] = []; + const { unsubscribe } = lib.subscribe({ + onChange: (c) => changes.push(c), + }); + unsubscribe(); + try { + client.collectionsQueue.push({ + collections: [collection(3, 300)], + deleted: [], + cursor: 300, + }); + client.filesFor(3, { + files: [file(3003, 3, 1_700_000_000_000_000)], + deleted: [], + cursor: 1_700_000_000_000_000, + }); + // The change lands in the store, but the cancelled subscriber sees + // nothing. + await vi.waitFor( + () => expect(lib.snapshot().albums.length).toBe(2), + { timeout: 2000, interval: 5 }, + ); + expect(changes).toEqual([]); + } finally { + lib.close(); + } + }); +}); -- 2.54.0