services and simplify

This commit is contained in:
timonrieger
2026-07-25 16:11:42 +02:00
parent b52d5d4ddb
commit c962016d4b
6 changed files with 273 additions and 62 deletions
+22 -21
View File
@@ -300,32 +300,29 @@ export type SearchOrder = z.infer<typeof SearchOrderSchema>;
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
const NEW_SHAPE_FIELDS = ['filter', 'orderBy', 'cursor'] as const;
export const isNewShapeRequest = (dto: Partial<Record<(typeof NEW_SHAPE_FIELDS)[number], unknown>>): boolean =>
NEW_SHAPE_FIELDS.some((field) => dto[field] !== undefined);
/**
* The structured shape and the deprecated flat search fields are
* mutually exclusive; carrier fields combine with either.
* The structured shape and the deprecated flat search fields are mutually exclusive
* TODO(v4): remove together with the deprecated flat fields.
*/
const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
schema.superRefine((dto, ctx) => {
const NEW_SHAPE_FIELDS = ['filter', 'orderBy', 'cursor'];
const CARRIER_FIELDS = new Set([
'size',
'withExif',
'withStacked',
'withPeople',
'query',
'queryAssetId',
'language',
]);
const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) => {
const deprecatedFields = Object.keys(schema.shape).filter(
(field) => (schema.shape[field] as z.ZodType).meta()?.deprecated,
);
return schema.superRefine((dto, ctx) => {
const values = dto as Record<string, unknown>;
const newShapeFields = NEW_SHAPE_FIELDS.filter((field) => values[field] !== undefined);
if (newShapeFields.length === 0) {
return;
}
for (const [field, value] of Object.entries(values)) {
if (value === undefined || CARRIER_FIELDS.has(field) || NEW_SHAPE_FIELDS.includes(field)) {
for (const field of deprecatedFields) {
if (values[field] === undefined) {
continue;
}
@@ -336,11 +333,15 @@ const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =
});
}
});
};
const filterField = SearchFilterSchema.optional().meta(ADDED_V3_1);
const cursorField = z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1);
const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({
withStacked: z.boolean().optional().describe('Include stacked assets'),
withPeople: z.boolean().optional().describe('Include people data in response'),
filter: SearchFilterSchema.optional().meta(ADDED_V3_1),
filter: filterField,
});
const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' });
@@ -358,14 +359,14 @@ const MetadataSearchSchema = withShapeExclusivity(
order: AssetOrderSchema.optional().describe('Sort order').meta(DEPRECATED_FLAT_FIELD),
page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD),
orderBy: SearchOrderSchema.optional().meta(ADDED_V3_1),
cursor: z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1),
cursor: cursorField,
}),
).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = withShapeExclusivity(
BaseSearchSchema.extend({
description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD),
filter: SearchFilterSchema.optional().meta(ADDED_V3_1),
filter: filterField,
}),
).meta({ id: 'StatisticsSearchDto' });
@@ -375,8 +376,8 @@ const SmartSearchSchema = withShapeExclusivity(
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
language: z.string().optional().describe('Search language code'),
page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD),
filter: SearchFilterSchema.optional().meta(ADDED_V3_1),
cursor: z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1),
filter: filterField,
cursor: cursorField,
}),
).meta({ id: 'SmartSearchDto' });
+147 -28
View File
@@ -1,14 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { LRUMap } from 'mnemonist';
import { SystemConfig } from 'src/config';
import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import {
isNewShapeRequest,
LargeAssetSearchDto,
mapPlaces,
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchFilter,
SearchPeopleDto,
SearchPlacesDto,
SearchResponseDto,
@@ -23,6 +26,13 @@ import { BaseService } from 'src/services/base.service';
import { requireElevatedPermission } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { isSmartSearchEnabled } from 'src/utils/misc';
import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor';
import {
applyLockedVisibilityPolicy,
collectFilterIds,
hasTopLevelPositiveIdsConstraint,
isLockedOnlyFilter,
} from 'src/utils/search-filter';
@Injectable()
export class SearchService extends BaseService {
@@ -63,6 +73,10 @@ export class SearchService extends BaseService {
}
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
if (isNewShapeRequest(dto)) {
return this.searchMetadataV3(auth, dto);
}
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
}
@@ -96,10 +110,14 @@ export class SearchService extends BaseService {
},
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null });
}
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
if (isNewShapeRequest(dto)) {
return this.searchStatisticsV3(auth, dto);
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
@@ -113,6 +131,10 @@ export class SearchService extends BaseService {
}
async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
if (isNewShapeRequest(dto)) {
return this.searchRandomV3(auth, dto);
}
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
}
@@ -141,6 +163,10 @@ export class SearchService extends BaseService {
}
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
if (isNewShapeRequest(dto)) {
return this.searchSmartV3(auth, dto);
}
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);
}
@@ -151,28 +177,7 @@ export class SearchService extends BaseService {
}
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
let embedding;
if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language;
embedding = this.embeddingCache.get(key);
if (!embedding) {
embedding = await this.machineLearningRepository.encodeText(dto.query, {
modelName: machineLearning.clip.modelName,
language: dto.language,
});
this.embeddingCache.set(key, embedding);
}
} else if (dto.queryAssetId) {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] });
const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId);
const assetEmbedding = getEmbeddingResponse?.embedding;
if (!assetEmbedding) {
throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`);
}
embedding = assetEmbedding;
} else {
throw new BadRequestException('Either `query` or `queryAssetId` must be set');
}
const embedding = await this.resolveEmbedding(auth, dto, machineLearning);
const page = dto.page ?? 1;
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
@@ -185,7 +190,7 @@ export class SearchService extends BaseService {
},
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null });
}
async getAssetsByCity(auth: AuthDto): Promise<AssetResponseDto[]> {
@@ -229,6 +234,121 @@ export class SearchService extends BaseService {
}
}
private async searchMetadataV3(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
const filter = dto.filter ?? {};
const effectiveFilter = applyLockedVisibilityPolicy(auth, filter);
// only a top-level any/all constrains every result to accessible albums; anything else keeps
// the ownership scope so an OR branch cannot widen the search to other users' assets
const hasTopLevelAlbums = hasTopLevelPositiveIdsConstraint(filter, 'albumIds');
if (auth.sharedLink && !hasTopLevelAlbums) {
throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter');
}
const albumIds = collectFilterIds(filter, 'albumIds');
const [userIds] = await Promise.all([
hasTopLevelAlbums ? undefined : this.getUserIdsToSearchV3(auth, filter),
albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined,
]);
const { offset } = decodeSearchCursor(dto.cursor);
const size = dto.size || 250;
const { hasNextPage, items } = await this.searchRepository.searchMetadataV3(
{ size, offset },
{
filter: effectiveFilter,
userIds,
withExif: dto.withExif,
withPeople: dto.withPeople,
withStacked: dto.withStacked,
order: dto.orderBy,
},
);
return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null });
}
private async searchStatisticsV3(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
const { effectiveFilter, userIds } = await this.resolveSearchScopeV3(auth, dto);
return this.searchRepository.searchStatisticsV3({ filter: effectiveFilter, userIds });
}
private async searchRandomV3(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
const { effectiveFilter, userIds } = await this.resolveSearchScopeV3(auth, dto);
const items = await this.searchRepository.searchRandomV3(dto.size || 250, {
filter: effectiveFilter,
userIds,
withExif: dto.withExif,
withPeople: dto.withPeople,
withStacked: dto.withStacked,
});
return items.map((item) => mapAsset(item, { auth }));
}
private async searchSmartV3(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
const { machineLearning } = await this.getConfig({ withCache: false });
if (!isSmartSearchEnabled(machineLearning)) {
throw new BadRequestException('Smart search is not enabled');
}
const [{ effectiveFilter, userIds }, embedding] = await Promise.all([
this.resolveSearchScopeV3(auth, dto),
this.resolveEmbedding(auth, dto, machineLearning),
]);
const { offset } = decodeSearchCursor(dto.cursor);
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmartV3(
{ size, offset },
{ filter: effectiveFilter, userIds, withExif: dto.withExif, embedding },
);
return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null });
}
private async resolveSearchScopeV3(auth: AuthDto, dto: { filter?: SearchFilter }) {
const filter = dto.filter ?? {};
return {
effectiveFilter: applyLockedVisibilityPolicy(auth, filter),
userIds: await this.getUserIdsToSearchV3(auth, filter),
};
}
private getUserIdsToSearchV3(auth: AuthDto, filter: SearchFilter): Promise<string[]> {
return this.getUserIdsToSearch(auth, isLockedOnlyFilter(filter) ? AssetVisibility.Locked : undefined);
}
private async resolveEmbedding(
auth: AuthDto,
dto: SmartSearchDto,
machineLearning: SystemConfig['machineLearning'],
): Promise<string> {
if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language;
let embedding = this.embeddingCache.get(key);
if (!embedding) {
embedding = await this.machineLearningRepository.encodeText(dto.query, {
modelName: machineLearning.clip.modelName,
language: dto.language,
});
this.embeddingCache.set(key, embedding);
}
return embedding;
}
if (dto.queryAssetId) {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] });
const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId);
const assetEmbedding = getEmbeddingResponse?.embedding;
if (!assetEmbedding) {
throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`);
}
return assetEmbedding;
}
throw new BadRequestException('Either `query` or `queryAssetId` must be set');
}
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
if (visibility === AssetVisibility.Locked) {
@@ -244,9 +364,8 @@ export class SearchService extends BaseService {
private mapResponse(
assets: MapAsset[],
nextPage: string | null,
options: AssetMapOptions,
nextCursor: string | null = null,
page: { nextPage?: string | null; nextCursor?: string | null } = {},
): SearchResponseDto {
return {
albums: { total: 0, count: 0, items: [], facets: [] },
@@ -255,8 +374,8 @@ export class SearchService extends BaseService {
count: assets.length,
items: assets.map((asset) => mapAsset(asset, options)),
facets: [],
nextPage,
nextCursor,
nextPage: page.nextPage ?? null,
nextCursor: page.nextCursor ?? null,
},
};
}
+4
View File
@@ -18,6 +18,10 @@ describe('encodeSearchCursor', () => {
describe('decodeSearchCursor', () => {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
it('should treat a missing cursor as the first page', () => {
expect(decodeSearchCursor(undefined)).toEqual({ offset: 0 });
});
it.each([
['empty string', ''],
['garbage that is not base64url', '!!!not-base64!!!'],
+6 -4
View File
@@ -5,12 +5,14 @@ const SearchCursorPayloadSchema = z.object({
o: z.int().min(0),
});
type SearchCursorPayload = z.infer<typeof SearchCursorPayloadSchema>;
export const encodeSearchCursor = (offset: number): string =>
Buffer.from(JSON.stringify({ o: offset } satisfies SearchCursorPayload)).toString('base64url');
Buffer.from(JSON.stringify({ o: offset } satisfies z.infer<typeof SearchCursorPayloadSchema>)).toString('base64url');
export const decodeSearchCursor = (cursor?: string): { offset: number } => {
if (cursor === undefined) {
return { offset: 0 };
}
export const decodeSearchCursor = (cursor: string): { offset: number } => {
try {
const payload = SearchCursorPayloadSchema.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')));
return { offset: payload.o };
+58 -2
View File
@@ -1,10 +1,16 @@
import { UnauthorizedException } from '@nestjs/common';
import { SearchFilter } from 'src/dtos/search.dto';
import { AssetVisibility } from 'src/enum';
import { applyLockedVisibilityPolicy } from 'src/utils/search-filter';
import {
applyLockedVisibilityPolicy,
collectFilterIds,
hasTopLevelPositiveIdsConstraint,
isLockedOnlyFilter,
} from 'src/utils/search-filter';
import { AuthFactory } from 'test/factories/auth.factory';
import { describe, expect, it } from 'vitest';
const { Locked, Timeline, Archive } = AssetVisibility;
const { Locked, Timeline, Archive, Hidden } = AssetVisibility;
const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build();
const unelevatedAuth = () => AuthFactory.from().session().build();
@@ -50,3 +56,53 @@ describe(applyLockedVisibilityPolicy.name, () => {
expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: Locked });
});
});
describe(isLockedOnlyFilter.name, () => {
it('should detect provably locked-only filters', () => {
expect(isLockedOnlyFilter({ visibility: { eq: Locked } })).toBe(true);
expect(isLockedOnlyFilter({ visibility: { in: [Locked] } })).toBe(true);
expect(isLockedOnlyFilter({ visibility: { notIn: [Timeline, Archive, Hidden] } })).toBe(true);
});
it('should not match other filters', () => {
expect(isLockedOnlyFilter({ visibility: { ne: Locked } })).toBe(false);
expect(isLockedOnlyFilter({ visibility: { in: [Locked, Timeline] } })).toBe(false);
expect(isLockedOnlyFilter({ or: [{ visibility: { eq: Locked } }] })).toBe(false);
expect(isLockedOnlyFilter({})).toBe(false);
});
});
describe(collectFilterIds.name, () => {
it('should union and dedupe ids across operators and branches', () => {
const [albumA, albumB, albumC] = [
'00000000-0000-4000-8000-00000000000a',
'00000000-0000-4000-8000-00000000000b',
'00000000-0000-4000-8000-00000000000c',
];
const filter: SearchFilter = {
albumIds: { any: [albumA], none: [albumB] },
or: [{ albumIds: { all: [albumC, albumA] } }, { city: { eq: 'Oslo' } }],
};
expect(collectFilterIds(filter, 'albumIds').toSorted()).toEqual([albumA, albumB, albumC]);
expect(collectFilterIds({}, 'albumIds')).toEqual([]);
});
it('should only collect ids of the requested field', () => {
const albumId = '00000000-0000-4000-8000-00000000000a';
const personId = '00000000-0000-4000-8000-00000000000b';
const filter = { albumIds: { any: [albumId] }, personIds: { any: [personId] } };
expect(collectFilterIds(filter, 'personIds')).toEqual([personId]);
});
});
describe(hasTopLevelPositiveIdsConstraint.name, () => {
it('should detect only top-level any/all constraints', () => {
const albumId = '00000000-0000-4000-8000-00000000000a';
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { any: [albumId] } }, 'albumIds')).toBe(true);
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { all: [albumId] } }, 'albumIds')).toBe(true);
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { none: [albumId] } }, 'albumIds')).toBe(false);
expect(hasTopLevelPositiveIdsConstraint({ or: [{ albumIds: { any: [albumId] } }] }, 'albumIds')).toBe(false);
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { any: [albumId] } }, 'tagIds')).toBe(false);
expect(hasTopLevelPositiveIdsConstraint({}, 'albumIds')).toBe(false);
});
});
+36 -7
View File
@@ -4,26 +4,32 @@ import { AssetVisibility } from 'src/enum';
import { requireElevatedPermission } from 'src/utils/access';
type EnumField = 'type' | 'visibility';
type EnumValue = NonNullable<NonNullable<SearchFilterBranch[EnumField]>['eq']>;
type EnumCondition = { eq?: EnumValue; ne?: EnumValue; in?: EnumValue[]; notIn?: EnumValue[] };
type EnumCondition<T> = { eq?: T; ne?: T; in?: T[]; notIn?: T[] };
type IdsFilterField = 'albumIds' | 'personIds' | 'tagIds';
const filterBranches = (filter: SearchFilter): SearchFilterBranch[] => [filter, ...(filter.or ?? [])];
/** Whether a row with `value` can satisfy the condition. A missing operator allows any value. */
const canMatch = (condition: EnumCondition, value: EnumValue): boolean =>
const canMatch = <T>(condition: EnumCondition<T>, value: T): boolean =>
(condition.eq === undefined || condition.eq === value) &&
(condition.ne === undefined || condition.ne !== value) &&
(condition.in === undefined || condition.in.includes(value)) &&
(condition.notIn === undefined || !condition.notIn.includes(value));
const matchesOnly = <T>(condition: EnumCondition<T>, value: T, values: T[]): boolean =>
values.every((other) => other === value || !canMatch(condition, other));
/**
* The conditions that decide which `field` values the filter can return. A top-level condition
* decides alone, otherwise each branch itself.
* The conditions that decide which `field` values the filter can return. A top-level condition decides alone, otherwise each branch itself.
*/
const decidingConditions = (filter: SearchFilter, field: EnumField): EnumCondition[] => {
const decidingConditions = <F extends EnumField>(filter: SearchFilter, field: F) => {
if (filter[field] !== undefined) {
return [filter[field]];
}
return (filter.or ?? []).map((branch) => branch[field]).filter((condition) => condition !== undefined);
return filterBranches(filter)
.map((branch) => branch[field])
.filter((condition) => condition !== undefined);
};
/**
@@ -45,3 +51,26 @@ export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter)
return { ...filter, visibility: { ne: AssetVisibility.Locked } };
};
/** Whether the top-level visibility condition limits the filter to locked assets only. */
export const isLockedOnlyFilter = ({ visibility }: SearchFilter): boolean =>
visibility !== undefined &&
matchesOnly(visibility, AssetVisibility.Locked, Object.values(AssetVisibility));
export const collectFilterIds = (filter: SearchFilter, field: IdsFilterField): string[] => {
const ids = new Set<string>();
for (const branch of filterBranches(filter)) {
for (const operator of ['any', 'all', 'none'] as const) {
for (const id of branch[field]?.[operator] ?? []) {
ids.add(id);
}
}
}
return [...ids];
};
/** Whether the top level constrains results to specific `field` ids (`any`/`all`; `none` only excludes). */
export const hasTopLevelPositiveIdsConstraint = (filter: SearchFilter, field: IdsFilterField): boolean =>
filter[field]?.any !== undefined || filter[field]?.all !== undefined;