// The library surface over the local cache. // // `Library.open()` loads the on-disk metadata store (issue #41), then starts // the refresh loop. When the cache loaded empty it awaits the first refresh, // so the library never opens onto an empty store it could have filled; when an // existing copy loaded, that first refresh runs in the background and `open()` // returns as soon as the cached data is ready to serve — a slow or unreachable // server no longer stalls opening. A background timer then refreshes every // `refreshIntervalSeconds`. Every default read is answered from RAM — no // default read touches the network. There is deliberately no `sync()`, no // `refresh()`, no `serverReachable` flag, and no "before each read" mode // (design #36). // // `fresh()` is the one exception (issue #75, an owner amendment to #36): it // forces a refresh, awaits it, and only then hands back the read namespaces, so // a caller that needs server-current data can ask for it. Concurrent `fresh()` // calls coalesce onto one in-flight refresh, and a refresh that fails rejects // the caller (the default reads stay silent and serve the last good copy). The // default methods and the background loop are unchanged. // // A refresh stages all of its network work first and only mutates the store // once every fetch has succeeded. A refresh that fails partway therefore never // becomes visible to reads: the last good snapshot stays in place, and the // failure surfaces through `onProgress` and `status()` instead. A commit that // mutates RAM but then fails to persist keeps `status().lastError` set and the // store marked unsaved until a later save actually lands, so a stuck disk is // never masked by a subsequent empty refresh. import { join } from "node:path"; import envPaths from "env-paths"; import { MetadataStore } from "./store.js"; import { MLDataStore } from "./mldata.js"; import { RequestPools } from "./pools.js"; import { deriveRecords, snapshotFrom, diffRecords, type DerivedRecords, type LibrarySnapshot, type LibraryChange, } from "./records.js"; import { makeAlbumsAPI, makePhotosAPI, makeTimelineAPI, type AlbumsAPI, type PhotosAPI, type TimelineAPI, type FreshReads, } from "./read.js"; import { ContentCache, type ContentSource, type ThumbnailsAPI, type EnsureOptions, type EnsureResult, } from "./content.js"; import { makeMLDataAPI, type MLDataAPI } from "./mlsearch.js"; import { Precache } from "./precache.js"; export { Album, Photo, type AlbumsAPI, type PhotosAPI, type TimelineAPI, type FreshReads, type PhotoFilter, type TimelineGroup, type GroupBy, } from "./read.js"; export { type ContentSource, type ContentResult, type ContentEvent, type ContentOptions, type PhotoContent, type ThumbnailsAPI, type ThumbnailPriority, type EnsureOptions, type EnsureResult, type EnsureEvent, } from "./content.js"; export { type MLDataAPI, type SimilarResult } from "./mlsearch.js"; import type { CollectionsPage, FilesPage } from "../client.js"; import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js"; import type { Collection, EnteFile } from "../model/types.js"; import { runBackup, type BackupOptions, type BackupResult } from "../backup.js"; export { runBackup, type BackupOptions, type BackupResult, type BackupError, } from "../backup.js"; export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3; // Project a metadata store into by-id records, filling each record's cache // paths from the content cache when one is given. Shared by the live read // projection and the precache's initial seeding at open(). const deriveRecordsFromStore = ( store: MetadataStore, cache?: ContentCache, ): DerivedRecords => { const collections = store.listCollections(); const files: EnteFile[] = []; for (const c of collections) files.push(...store.listFiles(c.id)); return deriveRecords( collections, files, cache ? (fileID) => cache.pathsFor(fileID) : undefined, ); }; // The slice of `Client` the library depends on. Narrowing to an interface lets // tests drive a mock with no crypto or network; the real `Client` satisfies it // structurally. export interface LibraryClient { whoami(): { email: string; userID: number }; collectionsSince(args: { sinceTime: number }): Promise; filesSince(args: { collectionID: number; collectionKey: Uint8Array; sinceTime: number; }): Promise; // Fetch ML data (face detections + CLIP embeddings) for up to a batch of // files. Optional: a client without it simply disables ML fetching, leaving // the metadata refresh untouched. fetchMLData?(args: { fileIDs: number[]; fileKeys: Map; }): Promise>; // The byte source for the on-disk content cache. Optional so a mock client // that only serves metadata still satisfies the interface; when absent (and // no explicit `contentSource` is passed to `open`) the content cache is // disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw. contentSource?(): ContentSource; } // A progress event for one unit of background work. A metadata "refresh" or an // ML "fetchMLData" pass each fire "started" before their network work and then // exactly one of "done" or "failed"; "failed" carries the error message and // an ML "done" reports how many payloads it stored. The precache fills // ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around // each sweep that has work, "done" reporting the count newly cached. export interface RefreshEvent { operation: | "refresh" | "fetchMLData" | "precacheThumbnails" | "precacheOriginals"; status: "started" | "done" | "failed"; error?: string; fetched?: number; } export type RefreshProgressCallback = (event: RefreshEvent) => void; export interface LibraryOptions { client: LibraryClient; // Where `metadata.json` lives. Defaults to the env-paths cache directory // plus the user id, so each account has its own cache. cacheDirectory?: string; // Persistent backup destination. The refresh loop does not use it; the // content cache treats an original already stored there as present. downloadDirectory?: string; refreshIntervalSeconds?: number; onProgress?: RefreshProgressCallback; // The bounded request pools (issue #45), shared by the ML-data fetch (the // metadata pool) and the content cache. Defaults to a fresh set at the // design's caps. pools?: RequestPools; // Overrides the client's own `contentSource()`; mainly for tests that drive // the cache with a stand-in source. contentSource?: ContentSource; // Bound on `cacheDirectory/originals` (default 100 GiB) and the free space // to protect on its volume (default 50 GiB). The effective limit adapts // down as the disk fills; `status().originalsLimitBytes` reports it. cacheOriginalsMaxBytes?: number; freeBelowBytes?: number; // An extra pinned predicate OR-ed with the precache's own pinned set // (favorites + latest week, #48). Pinned originals are never evicted. isOriginalPinned?: (fileID: number) => boolean; // The aggressive local precache (#48), all starting inside `open()` with no // caller input. Thumbnails: every file, newest first, until all are on // disk. Originals: the favorites album then the latest `precacheOriginalsDays` // window (the days ending at the newest file). Both default on; the days // default to 7. precacheThumbnails?: boolean; precacheOriginals?: boolean; precacheOriginalsDays?: number; } export interface LibraryStatus { userID: number; collections: number; files: number; // Wall-clock ms of the last refresh that succeeded, or undefined if none // has yet. lastRefreshAt?: number; // The message from the most recent refresh, set only while that refresh // failed; cleared by the next success. lastError?: string; // Wall-clock ms of the last ML fetch pass that succeeded, or undefined if // none has yet (or ML fetching is disabled). lastMLFetchAt?: number; // The most recent ML fetch pass's error, set only while it failed. lastMLError?: string; // ML payloads stored on disk and CLIP embeddings in the index; undefined // when ML fetching is disabled. mlStored?: number; mlIndexed?: number; // Bytes stored in the originals cache and the effective size limit as of the // last write or open; undefined when no content cache is open. originalsUsedBytes?: number; originalsLimitBytes?: number; // Precache progress (#48); undefined when no content cache is open. Totals // are the files targeted (0 when a fill is disabled); "cached" is how many // of them are on disk. thumbnailsCached?: number; thumbnailsTotal?: number; originalsCached?: number; originalsPinned?: number; closed: boolean; } export class Library { readonly cacheDirectory: string; readonly downloadDirectory?: string; // The in-process read surface (issue #44). Each namespace answers // synchronously from the live record projection; no read touches the // network. readonly albums: AlbumsAPI; readonly photos: PhotosAPI; readonly timeline: TimelineAPI; // The thumbnail-prefetch surface (issue #46): drives the thumbnail pool // with priority, dedup, and abort. readonly thumbnails: ThumbnailsAPI; // The content-similarity search surface over the CLIP index (issue #50). // Present whether or not ML fetching is enabled; with no ML store it // returns empty results. readonly mldata: MLDataAPI; private readonly client: LibraryClient; private readonly store: MetadataStore; // The on-disk content cache, or undefined when no content source is // available (a metadata-only client with no explicit source). private readonly cache?: ContentCache; private readonly userID: number; private readonly intervalMs: number; private readonly onProgress?: RefreshProgressCallback; private readonly pools: RequestPools; // The ML-data cache, present only when the client can fetch ML data. private readonly mlStore?: MLDataStore; // The local precache (#48), present only when the content cache is. private readonly precache?: Precache; private timer?: ReturnType; // The in-flight refresh cycle, or undefined when none runs. One slot serves // both paths: the background loop skips when it is set, and a fresh read // (issue #75) coalesces onto it or starts one. The promise carries the // cycle's real outcome (it rejects on failure); the background loop ignores // that, a fresh read propagates it. private cycle?: Promise; // Guards the ML fetch pass so a slow backfill never runs twice at once; a // refresh whose pass is still running kicks nothing new. Holds the running // pass, so `close()` can wait for it. private mlFetch?: Promise; private closed = false; private lastRefreshAt?: number; private lastError?: string; private lastMLFetchAt?: number; private lastMLError?: 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. private unsaved = false; private constructor(args: { client: LibraryClient; store: MetadataStore; userID: number; cacheDirectory: string; downloadDirectory?: string; intervalMs: number; onProgress?: RefreshProgressCallback; pools: RequestPools; mldata?: MLDataStore; cache?: ContentCache; precache?: Precache; }) { this.client = args.client; this.store = args.store; this.userID = args.userID; this.cacheDirectory = args.cacheDirectory; this.downloadDirectory = args.downloadDirectory; this.intervalMs = args.intervalMs; this.onProgress = args.onProgress; this.pools = args.pools; this.mlStore = args.mldata; this.cache = args.cache; this.precache = args.precache; this.lastRecords = this.deriveNow(); // The read namespaces derive fresh from the store on each call, so they // always reflect the latest refresh. const derive = (): DerivedRecords => this.deriveNow(); this.albums = makeAlbumsAPI(derive, this.cache); this.photos = makePhotosAPI(derive, this.cache); this.timeline = makeTimelineAPI(derive); this.thumbnails = { ensure: (opts: EnsureOptions): Promise => { if (!this.cache) { return Promise.reject( new Error( "thumbnails.ensure requires a library opened with a content cache", ), ); } return this.cache.ensureThumbnails(opts); }, }; // Reads the ML store live so results grow as ML data is fetched. this.mldata = makeMLDataAPI(() => this.mlStore); } // Load the cache and start the refresh loop. With an empty cache the first // refresh is awaited, so `open()` resolves onto populated data whenever the // server is reachable; that awaited refresh may still fail, and the library // then opens empty with the failure recorded in `status()`. With an // existing cache the first refresh runs in the background and `open()` // returns as soon as the cached data is ready — an unreachable server does // not block opening. static async open(opts: LibraryOptions): Promise { const { userID } = opts.client.whoami(); const cacheDirectory = opts.cacheDirectory ?? join(envPaths("quak", { suffix: "" }).cache, String(userID)); const store = await MetadataStore.load( join(cacheDirectory, "metadata.json"), ); const intervalMs = (opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) * 1000; // One request-pool set serves both the ML-data fetch and the content // cache, so both honour the same concurrency caps. const pools = opts.pools ?? new RequestPools(); // The ML cache only earns its keep when the client can fetch ML data; // a client without that capability opens no `mldata/` directory. const mldata = opts.client.fetchMLData ? await MLDataStore.open(join(cacheDirectory, "mldata")) : undefined; // Build the content cache from an explicit source or the client's own, // and take its record of what is already cached (and reap orphan temp // files) before the first projection, so cached paths are present from // the start and the first refresh raises no spurious path-change diff. const source = opts.contentSource ?? opts.client.contentSource?.(); let cache: ContentCache | undefined; let precache: Precache | undefined; if (source) { // The precache owns the pinned set (favorites + latest week), which // is the cache's eviction predicate. It is built and seeded from // the loaded store first so the cache can wire `isPinned` to it, and // then bound to the cache it fills. A caller-supplied predicate is // OR-ed in so both survive. precache = new Precache({ thumbnails: opts.precacheThumbnails, originals: opts.precacheOriginals, originalsDays: opts.precacheOriginalsDays, onEvent: opts.onProgress, }); precache.update(deriveRecordsFromStore(store)); const extraPinned = opts.isOriginalPinned; cache = new ContentCache({ pools, source, cacheDirectory, downloadDirectory: opts.downloadDirectory, getFile: (fileID) => store.getFileByID(fileID), cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes, freeBelowBytes: opts.freeBelowBytes, isPinned: (fileID) => precache!.isPinned(fileID) || (extraPinned?.(fileID) ?? false), }); await cache.open(); precache.bind(cache); } const lib = new Library({ client: opts.client, store, userID, cacheDirectory, downloadDirectory: opts.downloadDirectory, intervalMs, onProgress: opts.onProgress, pools, mldata, cache, precache, }); // Start filling from whatever the loaded store already holds; each // refresh below re-kicks with the new files (and retries any that // failed). An empty store starts empty here and fills after its first // refresh. precache?.start(); if (store.loadedFromDisk) { // An existing copy already answers reads; refresh in the background // and start the interval once that first cycle settles. void lib.runRefresh().then(() => lib.scheduleNext()); } else { // Nothing was cached: wait for the first refresh to fill the store // (or fail) rather than resolve onto an empty library. await lib.runRefresh(); lib.scheduleNext(); } return lib; } listCollections(): Collection[] { return this.store.listCollections(); } getCollection(id: number): Collection | undefined { return this.store.getCollection(id); } listFiles(collectionID: number): EnteFile[] { return this.store.listFiles(collectionID); } getFile(collectionID: number, fileID: number): EnteFile | undefined { return this.store.getFile(collectionID, fileID); } // Any membership of a file, addressed by file id alone. A file's own // metadata (title, creationTime) is identical across the collections it // belongs to, so this serves the point commands that hold only a fileID. getFileByID(fileID: number): EnteFile | undefined { return this.store.getFileByID(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(); for (const c of collections) { files += this.store.listFiles(c.id).length; } const ml = this.mlStore?.stats(); const originals = this.cache?.originalsStatus(); const pre = this.precache?.status(); return { userID: this.store.userID, collections: collections.length, files, lastRefreshAt: this.lastRefreshAt, lastError: this.lastError, lastMLFetchAt: this.lastMLFetchAt, lastMLError: this.lastMLError, mlStored: ml?.stored, mlIndexed: ml?.indexed, originalsUsedBytes: originals?.usedBytes, originalsLimitBytes: originals?.limitBytes, thumbnailsCached: pre?.thumbnailsCached, thumbnailsTotal: pre?.thumbnailsTotal, originalsCached: pre?.originalsCached, originalsPinned: pre?.originalsPinned, closed: this.closed, }; } // Fresh reads (issue #75, owner amendment to design #36). Force a refresh, // wait for it to complete and persist, then hand back the same // `albums`/`photos`/`timeline` namespaces — now guaranteed to reflect a // completed server round-trip. Concurrent calls coalesce onto one refresh; // a refresh that fails rejects here, where the default namespaces would // instead stay silent and serve the last good copy. async fresh(): Promise { await this.refreshNow(); return { albums: this.albums, photos: this.photos, timeline: this.timeline, }; } // Back up every in-scope file to `downloadDirectory` in the historical // on-disk layout, with a durable failure ledger (issue #51). Refreshes // first, fetches pending originals (and optional thumbnails) through the // content cache and pools, then rebuilds the derived symlink/JSON views // from the model. Throws before any network work when no download directory // is available or no content cache backs the originals it must fetch. backup(opts?: BackupOptions): Promise { const downloadDirectory = opts?.downloadDirectory ?? this.downloadDirectory; const includeOriginals = opts?.includeOriginals ?? true; const includeThumbnails = opts?.includeThumbnails ?? false; if (!downloadDirectory) { return Promise.reject( new Error( "backup requires a downloadDirectory (pass one to " + "backup() or open the library with one)", ), ); } if ((includeOriginals || includeThumbnails) && !this.cache) { return Promise.reject( new Error( "backup requires a library opened with a content cache", ), ); } const cache = this.cache; return runBackup( { refresh: () => this.runRefresh(), listCollections: () => this.store.listCollections(), listFiles: (id) => this.store.listFiles(id), original: (fileID) => cache!.original(fileID), thumbnail: (fileID) => cache!.thumbnail(fileID), }, { ...opts, downloadDirectory }, ); } // Stop the background timer. Idempotent. An in-flight refresh is left to // finish; it will not schedule another cycle once closed. The returned // promise resolves once that refresh (including its cache write), the ML // fetch pass and the precache fetches already running have all finished, // so a caller can then remove the cache directory. A refresh failure is // reported through `status()`, not thrown here. async close(): Promise { this.closed = true; const precacheClosed = this.precache?.close(); if (this.timer !== undefined) { clearTimeout(this.timer); this.timer = undefined; } await this.cycle?.catch(() => {}); await this.mlFetch; await precacheClosed; } private scheduleNext(): void { if (this.closed) return; this.timer = setTimeout(() => { void this.runRefresh().then(() => this.scheduleNext()); }, this.intervalMs); // Do not keep the process alive for the sake of the timer. this.timer.unref?.(); } // The background loop's refresh: run a cycle unless one is already in flight // (or the library is closed), and never let a failure escape — the // background path reports errors through `status()`/`onProgress`, it does // not throw. Resolves once the cycle it started (or skipped past) settles. private runRefresh(): Promise { if (this.closed || this.cycle) return Promise.resolve(); return this.startCycle().catch(() => {}); } // A fresh read's refresh (issue #75): force a cycle and await it, rejecting // if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle // — the background loop's included — so they never fan out into redundant // server round-trips. private refreshNow(): Promise { if (this.closed) { return Promise.reject(new Error("the library is closed")); } return this.cycle ?? this.startCycle(); } // Start one refresh cycle and record it as the in-flight cycle so every // caller coalesces onto it. The returned promise carries the cycle's real // outcome; each caller attaches the handling its own path needs, and the // slot is cleared once the cycle settles. private startCycle(): Promise { const cycle = this.refreshCycle(); this.cycle = cycle; void cycle.then( () => { if (this.cycle === cycle) this.cycle = undefined; }, () => { if (this.cycle === cycle) this.cycle = undefined; }, ); return cycle; } // One refresh cycle: the network fetch and commit, wrapped in the progress // events and status bookkeeping. Throws when the refresh fails so a fresh // read can reject; `runRefresh` swallows that throw for the background loop. private async refreshCycle(): Promise { this.emit({ operation: "refresh", status: "started" }); try { await this.refreshOnce(); this.lastRefreshAt = Date.now(); this.lastError = undefined; this.emit({ operation: "refresh", status: "done" }); // Backfill ML data for the files this refresh knows about. It runs // outside the refresh's success/failure so a fetch or disk problem // there never marks the metadata refresh failed, and it is not // awaited so it never stalls the refresh interval. this.mlFetch ??= this.runMLFetch().finally(() => { this.mlFetch = undefined; }); } catch (err) { const error = err instanceof Error ? err.message : String(err); this.lastError = error; this.emit({ operation: "refresh", status: "failed", error }); throw err; } } // Fetch every change since the stored cursor, then commit. All network // reads happen before any store mutation, so a fetch that throws leaves the // store untouched and the previous snapshot intact. private async refreshOnce(): Promise { const page = await this.client.collectionsSince({ sinceTime: this.store.collectionsSinceTime, }); // Stage per-collection file diffs. A collection's files are // re-enumerated only when its updationTime has advanced past the cached // copy; an unchanged album's file list cannot have changed. New // collections enumerate from the beginning of time. const filePages: { collectionID: number; page: FilesPage }[] = []; for (const collection of page.collections) { const known = this.store.getCollection(collection.id); if (known && collection.updationTime <= known.updationTime) continue; const filePage = await this.client.filesSince({ collectionID: collection.id, collectionKey: collection.key, sinceTime: known ? known.updationTime : 0, }); filePages.push({ collectionID: collection.id, page: filePage }); } // Network work done; commit to the store and persist only if something // actually changed. let changed = false; if (this.store.userID !== this.userID) { this.store.userID = this.userID; changed = true; } for (const id of page.deleted) { if (this.store.getCollection(id)) { this.store.deleteCollection(id); changed = true; } } for (const collection of page.collections) { this.store.putCollection(collection); changed = true; } for (const { collectionID, page: filePage } of filePages) { for (const id of filePage.deleted) { if (this.store.getFile(collectionID, id)) { this.store.deleteFile(collectionID, id); changed = true; } } for (const f of filePage.files) { this.store.putFile(f); changed = true; } } if (page.cursor !== this.store.collectionsSinceTime) { this.store.collectionsSinceTime = page.cursor; changed = true; } 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; // Recompute the fill orders and pinned set against the new library. this.precache?.update(next); } // Re-kick the fills every cycle: a finished sweep starts afresh to pick // up new files and retry any that failed, and a running one is left be. this.precache?.start(); // 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 // `status().lastError` (the throw below records it) and keeps being // retried, instead of a later empty refresh silently clearing it while // the on-disk cache is still behind RAM. if (this.unsaved) { await this.store.save(); this.unsaved = false; } } // One ML fetch pass: fetch, decrypt and store the ML data for every file // the store knows about that is not cached (or whose `updationTime` has // advanced), through the metadata pool, and update the CLIP index. Guarded // so passes never overlap; a failure is reported, not thrown. private async runMLFetch(): Promise { const mldata = this.mlStore; // Bind so the call keeps the client as its receiver when invoked // through the pool below. const fetchMLData = this.client.fetchMLData?.bind(this.client); if (!mldata || !fetchMLData || this.closed) return; const files = this.uniqueFiles(); const needed = mldata.neededFor(files); if (needed.length === 0) return; this.emit({ operation: "fetchMLData", status: "started" }); try { const fileKeys = new Map(); const updation = new Map(); for (const f of files) { fileKeys.set(f.id, f.key); updation.set(f.id, f.updationTime); } let stored = 0; for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) { if (this.closed) break; const batch = needed.slice(i, i + MLDATA_BATCH_SIZE); const payloads = await this.pools.metadata.run( () => fetchMLData({ fileIDs: batch, fileKeys }), { priority: "background" }, ); stored += (await mldata.storeFetched(payloads, updation)) .stored; } this.lastMLFetchAt = Date.now(); this.lastMLError = undefined; this.emit({ operation: "fetchMLData", status: "done", fetched: stored, }); } catch (err) { const error = err instanceof Error ? err.message : String(err); this.lastMLError = error; this.emit({ operation: "fetchMLData", status: "failed", error }); } } // The distinct files the store holds, one entry per fileID (a file in // several collections shares its ML data), each carrying the key and the // newest `updationTime` seen across its memberships. private uniqueFiles(): { id: number; key: Uint8Array; updationTime: number; }[] { const byID = new Map< number, { id: number; key: Uint8Array; updationTime: number } >(); for (const collection of this.store.listCollections()) { for (const f of this.store.listFiles(collection.id)) { const seen = byID.get(f.id); if (seen === undefined || f.updationTime > seen.updationTime) byID.set(f.id, { id: f.id, key: f.key, updationTime: f.updationTime, }); } } return [...byID.values()]; } // Gather every file membership and project the store into by-id records, // filling each record's cache paths from the content cache when present. private deriveNow(): DerivedRecords { return deriveRecordsFromStore(this.store, this.cache); } 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. try { this.onProgress(event); } catch { // ignore } } }