1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2025-11-23 22:24:44 +02:00

Implementing ProjectedPersonCacheEntity #1015

This commit is contained in:
Patrik J. Braun
2025-09-08 20:43:59 +02:00
parent af3d475300
commit 13bce80da2
10 changed files with 361 additions and 139 deletions

View File

@@ -28,8 +28,9 @@ import {
TextSearchQueryMatchTypes,
TextSearchQueryTypes
} from '../src/common/entities/SearchQueryDTO';
import {defaultQueryKeywords, QueryKeywords, SearchQueryParser} from '../src/common/SearchQueryParser';
import {defaultQueryKeywords, SearchQueryParser} from '../src/common/SearchQueryParser';
import {ParentDirectoryDTO} from '../src/common/entities/DirectoryDTO';
import {SessionManager} from '../src/backend/model/database/SessionManager';
import {SessionContext} from '../src/backend/model/SessionContext';
@@ -79,9 +80,11 @@ export class BenchmarkRunner {
query: {},
session: {}
};
private session: SessionContext;
constructor(public RUNS: number) {
Config.Users.authenticationRequired = false;
this.session = {user: {projectionKey: SessionManager.NO_PROJECTION_KEY}} as SessionContext;
}
async bmSaveDirectory(): Promise<BenchmarkResult[]> {
@@ -150,7 +153,7 @@ export class BenchmarkRunner {
await this.setupDB();
const queryParser = new SearchQueryParser(defaultQueryKeywords);
const names = (await ObjectManagers.getInstance().PersonManager.getAll()).sort((a, b) => b.count - a.count);
const names = (await ObjectManagers.getInstance().PersonManager.getAll(this.session)).sort((a, b) => b.count - a.count);
const queries: { query: SearchQueryDTO, description: string }[] = TextSearchQueryTypes.map(t => {
const q = {
type: t, text: 'a'
@@ -255,7 +258,7 @@ export class BenchmarkRunner {
', videos: ' + await gm.countVideos() +
', diskUsage : ' + Utils.renderDataSize(await gm.countMediaSize()) +
', persons : ' + await pm.countFaces() +
', unique persons (faces): ' + (await pm.getAll()).length;
', unique persons (faces): ' + (await pm.getAll(this.session)).length;
}
@@ -269,8 +272,7 @@ export class BenchmarkRunner {
const queue = ['/'];
while (queue.length > 0) {
const dirPath = queue.shift();
const session = new SessionContext();
const dir = await gm.listDirectory(session,dirPath);
const dir = await gm.listDirectory(this.session, dirPath);
dir.directories.forEach((d): number => queue.push(path.join(d.path + d.name)));
if (biggest < dir.media.length) {
biggestPath = path.join(dir.path + dir.name);

View File

@@ -4,7 +4,7 @@ Author: Junie (JetBrains autonomous programmer)
Date: 2025-08-13
Status: Updated after maintainer clarifications
Terminology: The project uses the term projection. Earlier drafts and filenames may still say “scoped”; whenever you see “scoped,” read it as “projection.” Benchmark file names keep “scoped” for historical reasons.
Terminology: The project uses the term projection. Earlier drafts and filenames may still say “projected”; whenever you see “projected,” read it as “projection.” Benchmark file names keep “projected” for historical reasons.
## Overview
This document proposes a design to support:
@@ -302,11 +302,11 @@ Purpose
- Variant B (alternative): separate ProjectionKey table (unique key hash), projection tables reference it via projectionId (FK ON DELETE CASCADE).
Benchmark harness
- Location: benchmark\\scoped-cache-bench.ts (historical filename)
- Run: npm run bench:scoped-cache
- Location: benchmark\\projected-cache-bench.ts (historical filename)
- Run: npm run bench:projected-cache
- Params (env vars): BENCH_SCOPES, BENCH_DIRS, BENCH_PERSONS, BENCH_LOOKUPS. Example (Windows cmd):
- set BENCH_SCOPES=20&&set BENCH_DIRS=120&&set BENCH_PERSONS=90&&set BENCH_LOOKUPS=1000&&npm run bench:scoped-cache
- DB: temporary SQLite file at db\\scoped_bench.sqlite (does not touch app DB)
- set BENCH_SCOPES=20&&set BENCH_DIRS=120&&set BENCH_PERSONS=90&&set BENCH_LOOKUPS=1000&&npm run bench:projected-cache
- DB: temporary SQLite file at db\\projected_bench.sqlite (does not touch app DB)
- Measures: upsert throughput for dir/person tables, lookup latency by (projection, directory), cascade delete performance, file size delta.
Sample results (SQLite, N=20, D=120, P=90, lookups=1000)

View File

@@ -7,9 +7,9 @@ import {PersonEntry} from '../model/database/enitites/PersonEntry';
export class PersonMWs {
public static async updatePerson(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
if (!req.params['name']) {
return next();
@@ -17,72 +17,72 @@ export class PersonMWs {
try {
req.resultPipe =
await ObjectManagers.getInstance().PersonManager.updatePerson(
req.params['name'] as string,
req.body as PersonDTO
);
await ObjectManagers.getInstance().PersonManager.updatePerson(
req.params['name'] as string,
req.body as PersonDTO
);
return next();
} catch (err) {
return next(
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during updating a person',
err
)
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during updating a person',
err
)
);
}
}
public static async getPerson(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
if (!req.params['name']) {
return next();
}
try {
req.resultPipe = await ObjectManagers.getInstance().PersonManager.get(
req.params['name'] as string
req.resultPipe = await ObjectManagers.getInstance().PersonManager.get(req.session.context,
req.params['name'] as string
);
return next();
} catch (err) {
return next(
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during updating a person',
err
)
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during updating a person',
err
)
);
}
}
public static async listPersons(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
req.resultPipe =
await ObjectManagers.getInstance().PersonManager.getAll();
await ObjectManagers.getInstance().PersonManager.getAll(req.session.context);
return next();
} catch (err) {
return next(
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during listing persons',
err
)
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during listing persons',
err
)
);
}
}
public static async cleanUpPersonResults(
req: Request,
res: Response,
next: NextFunction
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
if (!req.resultPipe) {
return next();
@@ -96,11 +96,11 @@ export class PersonMWs {
return next();
} catch (err) {
return next(
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during removing sample photo from all persons',
err
)
new ErrorDTO(
ErrorCodes.PERSON_ERROR,
'Error during removing sample photo from all persons',
err
)
);
}
}

View File

@@ -5,57 +5,181 @@ import {Logger} from '../../Logger';
import {SQL_COLLATE} from './enitites/EntityUtils';
import {PersonJunctionTable} from './enitites/PersonJunctionTable';
import {IObjectManager} from './IObjectManager';
import {ParentDirectoryDTO} from '../../../common/entities/DirectoryDTO';
import {ProjectedPersonCacheEntity} from './enitites/ProjectedPersonCacheEntity';
import {SessionContext} from '../SessionContext';
const LOG_TAG = '[PersonManager]';
export class PersonManager implements IObjectManager {
persons: PersonEntry[] = null;
/**
* Person table contains denormalized data that needs to update when isDBValid = false
*/
private isDBValid = false;
personsCache: Record<string, PersonEntry[]> = null;
private static async updateCounts(): Promise<void> {
const connection = await SQLConnection.getConnection();
await connection.query(
'UPDATE person_entry SET count = ' +
' (SELECT COUNT(1) FROM person_junction_table WHERE person_junction_table.personId = person_entry.id)'
'UPDATE person_entry SET count = ' +
' (SELECT COUNT(1) FROM person_junction_table WHERE person_junction_table.personId = person_entry.id)'
);
// remove persons without photo
await connection
.createQueryBuilder()
.delete()
.from(PersonEntry)
.where('count = 0')
.execute();
.createQueryBuilder()
.delete()
.from(PersonEntry)
.where('count = 0')
.execute();
}
private static async updateSamplePhotos(): Promise<void> {
const connection = await SQLConnection.getConnection();
await connection.query(
'update person_entry set sampleRegionId = ' +
'(Select person_junction_table.id from media_entity ' +
'left join person_junction_table on media_entity.id = person_junction_table.mediaId ' +
'where person_junction_table.personId=person_entry.id ' +
'order by media_entity.metadataRating desc, ' +
'media_entity.metadataCreationdate desc ' +
'limit 1)'
'update person_entry set sampleRegionId = ' +
'(Select person_junction_table.id from media_entity ' +
'left join person_junction_table on media_entity.id = person_junction_table.mediaId ' +
'where person_junction_table.personId=person_entry.id ' +
'order by media_entity.metadataRating desc, ' +
'media_entity.metadataCreationdate desc ' +
'limit 1)'
);
}
private async updateCacheForAll(session: SessionContext): Promise<void> {
const connection = await SQLConnection.getConnection();
const projectionKey = session.user.projectionKey;
// Get all persons that need cache updates (either missing or invalid)
const personsNeedingUpdate = await connection
.getRepository(PersonEntry)
.createQueryBuilder('person')
.leftJoin(
ProjectedPersonCacheEntity,
'cache',
'cache.person = person.id AND cache.projectionKey = :projectionKey',
{projectionKey}
)
.where('cache.id IS NULL OR cache.valid = false')
.getMany();
if (personsNeedingUpdate.length === 0) {
return;
}
// Process persons in batches to avoid memory issues
const batchSize = 200;
for (let i = 0; i < personsNeedingUpdate.length; i += batchSize) {
const batch = personsNeedingUpdate.slice(i, i + batchSize);
const personIds = batch.map(p => p.id);
// Build base query for person junction table with projection constraints
const baseQb = connection
.getRepository(PersonJunctionTable)
.createQueryBuilder('pjt')
.innerJoin('pjt.media', 'media')
.where('pjt.person IN (:...personIds)', {personIds});
// Apply projection query if it exists
if (session.projectionQuery) {
baseQb.andWhere(session.projectionQuery);
}
// Compute counts per person
const countResults = await baseQb
.clone()
.select(['pjt.person as personId', 'COUNT(*) as count'])
.groupBy('pjt.person')
.getRawMany();
// Compute sample regions per person (best rated/newest photo)
// Use individual queries per person to ensure compatibility with older SQLite versions
const topSamples: Record<number, number> = {};
for (const personId of personIds) {
const sampleQb = connection
.getRepository(PersonJunctionTable)
.createQueryBuilder('pjt')
.innerJoin('pjt.media', 'media')
.where('pjt.person = :personId', {personId});
// Apply projection query if it exists
if (session.projectionQuery) {
sampleQb.andWhere(session.projectionQuery);
}
const sampleResult = await sampleQb
.select('pjt.id')
.orderBy('media.metadataRating', 'DESC')
.addOrderBy('media.metadataCreationdate', 'DESC')
.limit(1)
.getOne();
if (sampleResult) {
topSamples[personId] = sampleResult.id;
}
}
// Build count lookup
const counts = countResults.reduce((acc: Record<number, number>, r: any) => {
acc[parseInt(r.personId, 10)] = parseInt(r.count, 10);
return acc;
}, {});
// Batch upsert cache entries to minimize DB transactions
const cacheRepo = connection.getRepository(ProjectedPersonCacheEntity);
const cacheEntriesToSave: ProjectedPersonCacheEntity[] = [];
// Get existing cache entries for this batch
const existingEntries = await cacheRepo
.createQueryBuilder('cache')
.where('cache.projectionKey = :projectionKey', {projectionKey})
.andWhere('cache.person IN (:...personIds)', {personIds})
.getMany();
const existingByPersonId = existingEntries.reduce((acc, entry) => {
acc[entry.person.id] = entry;
return acc;
}, {} as Record<number, ProjectedPersonCacheEntity>);
for (const person of batch) {
const count = counts[person.id] || 0;
const sampleRegionId = topSamples[person.id] || null;
let cacheEntry = existingByPersonId[person.id];
if (cacheEntry) {
// Update existing entry
cacheEntry.count = count;
cacheEntry.sampleRegion = sampleRegionId ? {id: sampleRegionId} as any : null;
cacheEntry.valid = true;
} else {
// Create new entry
cacheEntry = new ProjectedPersonCacheEntity();
cacheEntry.projectionKey = projectionKey;
cacheEntry.person = {id: person.id} as any;
cacheEntry.count = count;
cacheEntry.sampleRegion = sampleRegionId ? {id: sampleRegionId} as any : null;
cacheEntry.valid = true;
}
cacheEntriesToSave.push(cacheEntry);
}
// Batch save all cache entries for this batch
if (cacheEntriesToSave.length > 0) {
await cacheRepo.save(cacheEntriesToSave);
}
}
}
async updatePerson(
name: string,
partialPerson: PersonDTO
name: string,
partialPerson: PersonDTO
): Promise<PersonEntry> {
this.isDBValid = false;
const connection = await SQLConnection.getConnection();
const repository = connection.getRepository(PersonEntry);
const person = await repository
.createQueryBuilder('person')
.limit(1)
.where('person.name LIKE :name COLLATE ' + SQL_COLLATE, {name})
.getOne();
.createQueryBuilder('person')
.limit(1)
.where('person.name LIKE :name COLLATE ' + SQL_COLLATE, {name})
.getOne();
if (typeof partialPerson.name !== 'undefined') {
person.name = partialPerson.name;
@@ -65,16 +189,16 @@ export class PersonManager implements IObjectManager {
}
await repository.save(person);
await this.loadAll();
await this.resetMemoryCache();
return person;
}
public async getAll(): Promise<PersonEntry[]> {
if (this.persons === null) {
await this.loadAll();
public async getAll(session: SessionContext): Promise<PersonEntry[]> {
if (!this.personsCache?.[session.user.projectionKey]) {
await this.loadAll(session);
}
return this.persons;
return this.personsCache[session.user.projectionKey];
}
/**
@@ -83,20 +207,20 @@ export class PersonManager implements IObjectManager {
public async countFaces(): Promise<number> {
const connection = await SQLConnection.getConnection();
return await connection
.getRepository(PersonJunctionTable)
.createQueryBuilder('personJunction')
.getCount();
.getRepository(PersonJunctionTable)
.createQueryBuilder('personJunction')
.getCount();
}
public async get(name: string): Promise<PersonEntry> {
if (this.persons === null) {
await this.loadAll();
public async get(session: SessionContext, name: string): Promise<PersonEntry> {
if (!this.personsCache?.[session.user.projectionKey]) {
await this.loadAll(session);
}
return this.persons.find((p): boolean => p.name === name);
return this.personsCache[session.user.projectionKey].find((p): boolean => p.name === name);
}
public async saveAll(
persons: { name: string; mediaId: number }[]
persons: { name: string; mediaId: number }[]
): Promise<void> {
const toSave: { name: string; mediaId: number }[] = [];
const connection = await SQLConnection.getConnection();
@@ -107,7 +231,7 @@ export class PersonManager implements IObjectManager {
// filter already existing persons
for (const personToSave of persons) {
const person = savedPersons.find(
(p): boolean => p.name === personToSave.name
(p): boolean => p.name === personToSave.name
);
if (!person) {
toSave.push(personToSave);
@@ -119,50 +243,91 @@ export class PersonManager implements IObjectManager {
const saving = toSave.slice(i * 200, (i + 1) * 200);
// saving person
const inserted = await personRepository.insert(
saving.map((p) => ({name: p.name}))
saving.map((p) => ({name: p.name}))
);
// saving junction table
const junctionTable = inserted.identifiers.map((idObj, j) => ({person: idObj, media: {id: saving[j].mediaId}}));
await personJunction.insert(junctionTable);
}
}
this.isDBValid = false;
}
public async onNewDataVersion(): Promise<void> {
await this.resetPreviews();
public async onNewDataVersion(changedDir?: ParentDirectoryDTO): Promise<void> {
await this.invalidateCacheForDir(changedDir);
}
protected async invalidateCacheForDir(changedDir?: ParentDirectoryDTO): Promise<void> {
if (!changedDir || !changedDir.id) {
return;
}
try {
const connection = await SQLConnection.getConnection();
// Collect affected person ids from this directory (non-recursive)
const rows = await connection.getRepository(PersonJunctionTable)
.createQueryBuilder('pjt')
.innerJoin('pjt.media', 'm')
.innerJoin('m.directory', 'd')
.innerJoin('pjt.person', 'person')
.where('d.id = :dirId', {dirId: changedDir.id})
.select('DISTINCT person.id', 'pid')
.getRawMany();
const pids = rows.map((r: any) => parseInt(r.pid, 10)).filter((n: number) => !isNaN(n));
if (pids.length === 0) {
return;
}
// Mark projection-aware person cache entries invalid for these persons
await connection.getRepository(ProjectedPersonCacheEntity)
.createQueryBuilder()
.update()
.set({valid: false})
.where('personId IN (:...pids)', {pids})
.execute();
this.personsCache = null;
} catch (err) {
Logger.warn(LOG_TAG, 'Failed to invalidate projected person cache on data change', err);
}
}
private resetMemoryCache(): void {
this.personsCache = null;
}
public async resetPreviews(): Promise<void> {
this.persons = null;
this.isDBValid = false;
const connection = await SQLConnection.getConnection();
await connection.getRepository(ProjectedPersonCacheEntity)
.createQueryBuilder()
.update()
.set({valid: false})
.execute();
this.resetMemoryCache();
}
private async loadAll(): Promise<void> {
await this.updateDerivedValues();
private async loadAll(session: SessionContext): Promise<void> {
await this.updateCacheForAll(session);
const connection = await SQLConnection.getConnection();
const personRepository = connection.getRepository(PersonEntry);
this.persons = await personRepository.find({
relations: [
this.personsCache = this.personsCache || {};
this.personsCache[session.user.projectionKey] = await personRepository
.createQueryBuilder('person')
.leftJoin('person.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: session.user.projectionKey})
.leftJoin('cache.sampleRegion', 'sampleRegion')
.leftJoin('sampleRegion.media', 'media')
.leftJoin('media.directory', 'directory')
.select([
'person.id',
'person.name',
'person.isFavourite',
'cache.count',
'sampleRegion',
'sampleRegion.media',
'sampleRegion.media.directory',
'sampleRegion.media.metadata',
],
});
'media.name',
'directory.path',
'directory.name'
])
.getMany();
}
/**
* Person table contains derived, denormalized data for faster select, this needs to be updated after data change
* @private
*/
private async updateDerivedValues(): Promise<void> {
if (this.isDBValid === true) {
return;
}
Logger.debug(LOG_TAG, 'Updating derived persons data');
await PersonManager.updateCounts();
await PersonManager.updateSamplePhotos();
this.isDBValid = false;
}
}

View File

@@ -24,6 +24,7 @@ import {NotificationManager} from '../NotifocationManager';
import {PersonJunctionTable} from './enitites/PersonJunctionTable';
import {MDFileEntity} from './enitites/MDFileEntity';
import { ProjectedDirectoryCacheEntity } from './enitites/ProjectedDirectoryCacheEntity';
import { ProjectedPersonCacheEntity } from './enitites/ProjectedPersonCacheEntity';
const LOG_TAG = '[SQLConnection]';
@@ -61,7 +62,8 @@ export class SQLConnection {
SavedSearchEntity,
VersionEntity,
// projection-aware cache entries
ProjectedDirectoryCacheEntity
ProjectedDirectoryCacheEntity,
ProjectedPersonCacheEntity
];
private static connection: Connection = null;

View File

@@ -1,7 +1,8 @@
import {Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, Unique,} from 'typeorm';
import {Column, Entity, Index, OneToMany, PrimaryGeneratedColumn, Unique,} from 'typeorm';
import {PersonJunctionTable} from './PersonJunctionTable';
import {columnCharsetCS} from './EntityUtils';
import {PersonDTO} from '../../../../common/entities/PersonDTO';
import {ProjectedPersonCacheEntity} from './ProjectedPersonCacheEntity';
@Entity()
@Unique(['name'])
@@ -16,20 +17,16 @@ export class PersonEntry implements PersonDTO {
})
name: string;
@Column('int', {unsigned: true, default: 0})
count: number;
@Column({default: false})
isFavourite: boolean;
@OneToMany(() => PersonJunctionTable, (junctionTable) => junctionTable.person)
public faces: PersonJunctionTable[];
@ManyToOne(() => PersonJunctionTable, {
onDelete: 'SET NULL',
nullable: true,
})
sampleRegion: PersonJunctionTable;
@OneToMany(() => ProjectedPersonCacheEntity, (ppc) => ppc.person)
public cache: ProjectedPersonCacheEntity;
// does not store in the DB, temporal field
missingThumbnail?: boolean;

View File

@@ -0,0 +1,41 @@
import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import { PersonEntry } from './PersonEntry';
import { PersonJunctionTable } from './PersonJunctionTable';
import {PersonCacheDTO} from '../../../../common/entities/PersonDTO';
// Projection-aware cache for persons, analogous to ProjectedDirectoryCacheEntity
// Stores per-projection aggregates for a given person
@Entity()
@Unique(['projectionKey', 'person'])
export class ProjectedPersonCacheEntity implements PersonCacheDTO{
@PrimaryGeneratedColumn({ unsigned: true })
id: number;
// hash key of the projection (built by SessionManager)
@Index()
@Column({ type: 'text', select: false }) // not needed in payloads; used to select the right cache per session
projectionKey: string;
// the person this cache row is about
@Index()
@ManyToOne(() => PersonEntry, {
onDelete: 'CASCADE',
nullable: false,
})
person: PersonEntry;
// number of visible face regions for this person under the projection
@Column('int', { unsigned: true, default: 0 })
count: number;
// a PersonJunctionTable row id under the projection chosen by ranking
@ManyToOne(() => PersonJunctionTable, {
onDelete: 'SET NULL',
nullable: true,
})
sampleRegion: PersonJunctionTable;
// if false, sample (or other aggregates) need recomputation
@Column({ type: 'boolean', default: true })
valid: boolean;
}

View File

@@ -53,7 +53,9 @@ export class AlbumCoverFillingJob extends Job {
}
private async stepPersonsPreview(): Promise<boolean> {
await ObjectManagers.getInstance().PersonManager.getAll();
for (const session of this.availableSessions) {
await ObjectManagers.getInstance().PersonManager.getAll(session);
}
this.Progress.log('Updating Persons preview');
this.Progress.Processed++;
return false;

View File

@@ -1,9 +1,12 @@
export interface PersonDTO {
id: number;
name: string;
count: number;
missingThumbnail?: boolean;
isFavourite: boolean;
}
export interface PersonCacheDTO {
count: number;
}

View File

@@ -46,11 +46,22 @@ describe('PersonManager', (sqlHelper: DBTestHelper) => {
p2 = (dir.media.filter(m => m.name === p2.name)[0] as any);
pFaceLess = (dir.media[2] as any);
v = (dir.media.filter(m => m.name === v.name)[0] as any);
savedPerson = await (await SQLConnection.getConnection()).getRepository(PersonEntry).find({
relations: ['sampleRegion',
'sampleRegion.media',
'sampleRegion.media.directory']
});
savedPerson = await (await SQLConnection.getConnection()).getRepository(PersonEntry).createQueryBuilder('person')
.leftJoin('person.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: DBTestHelper.defaultSession.user.projectionKey})
.leftJoin('cache.sampleRegion', 'sampleRegion')
.leftJoin('sampleRegion.media', 'media')
.leftJoin('media.directory', 'directory')
.select([
'person.id',
'person.name',
'person.isFavourite',
'cache.count',
'sampleRegion',
'media.name',
'directory.path',
'directory.name'
])
.getMany();
};
@@ -67,13 +78,12 @@ describe('PersonManager', (sqlHelper: DBTestHelper) => {
const pm = new PersonManager();
const person = Utils.clone(savedPerson[0]);
const selected = Utils.clone(await pm.get('Boba Fett'));
delete selected.sampleRegion;
delete person.sampleRegion;
person.count = 1;
const selected = Utils.clone(await pm.get(DBTestHelper.defaultSession, 'Boba Fett'));
expect(selected.cache).to.be.not.undefined;
delete selected.cache;
delete person.cache;
expect(selected).to.deep.equal(person);
expect((await pm.get('Boba Fett') as PersonEntry).sampleRegion.media.name).to.deep.equal(p.name);
});