feat(server): new search API (#30179)

This commit is contained in:
Timon 2026-08-27 20:39:55 +02:00 committed by GitHub
parent 25c60ef99e
commit 8b3d6b320b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 5039 additions and 384 deletions

View file

@ -4,7 +4,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' hide AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility, SearchFilter;
class SearchService {
final _log = Logger("SearchService");

View file

@ -2,7 +2,7 @@ import 'package:immich_mobile/data/server/api_repository.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/utils/option.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' hide SearchFilter;
class SearchApiRepository extends ApiRepository {
final SearchApi _api;

View file

@ -40,6 +40,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
'SyncAssetV1': {'isEdited': false},
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
'SearchAssetResponseDto': {'nextCursor': null},
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
'WorkflowResponseDto': {'logging': false},
};

File diff suppressed because it is too large Load diff

View file

@ -2154,6 +2154,168 @@ export type SearchExploreResponseDto = {
fieldName: string;
items: SearchExploreItem[];
};
export type IdsFilter = {
all?: string[];
"any"?: string[];
none?: string[];
};
export type StringFilter = {
eq?: string;
"in"?: string[];
ne?: string;
notIn?: string[];
};
export type StringFilterNullable = {
eq?: string | null;
"in"?: string[];
ne?: string | null;
notIn?: string[];
};
export type DateFilter = {
eq?: string;
gt?: string;
gte?: string;
lt?: string;
lte?: string;
ne?: string;
};
export type StringPatternFilter = {
endsWith?: string;
eq?: string | null;
"in"?: string[];
like?: string;
ne?: string | null;
notIn?: string[];
notLike?: string;
startsWith?: string;
};
export type NumberFilter = {
eq?: number;
gt?: number;
gte?: number;
"in"?: number[];
lt?: number;
lte?: number;
ne?: number;
notIn?: number[];
};
export type BoolFilter = {
eq: boolean;
};
export type IdFilter = {
eq?: string;
ne?: string;
};
export type IdFilterNullable = {
eq?: string | null;
ne?: string | null;
};
export type StringSimilarityFilter = {
matches: string;
};
export type NumberFilterNullable = {
eq?: number | null;
gt?: number;
gte?: number;
"in"?: number[];
lt?: number;
lte?: number;
ne?: number | null;
notIn?: number[];
};
export type DateFilterNullable = {
eq?: string | null;
gt?: string;
gte?: string;
lt?: string;
lte?: string;
ne?: string | null;
};
export type EnumFilterAssetType = {
eq?: AssetTypeEnum;
"in"?: AssetTypeEnum[];
ne?: AssetTypeEnum;
notIn?: AssetTypeEnum[];
};
export type EnumFilterAssetVisibility = {
eq?: AssetVisibility;
"in"?: AssetVisibility[];
ne?: AssetVisibility;
notIn?: AssetVisibility[];
};
export type SearchFilterBranch = {
albumIds?: IdsFilter;
checksum?: StringFilter;
city?: StringFilterNullable;
country?: StringFilterNullable;
createdAt?: DateFilter;
description?: StringPatternFilter;
encodedVideoPath?: StringFilter;
fileSizeInBytes?: NumberFilter;
hasAlbums?: BoolFilter;
hasPeople?: BoolFilter;
hasTags?: BoolFilter;
id?: IdFilter;
isEncoded?: BoolFilter;
isFavorite?: BoolFilter;
isMotion?: BoolFilter;
isOffline?: BoolFilter;
lensModel?: StringFilterNullable;
libraryId?: IdFilterNullable;
make?: StringFilterNullable;
model?: StringFilterNullable;
ocr?: StringSimilarityFilter;
originalFileName?: StringPatternFilter;
originalPath?: StringPatternFilter;
personIds?: IdsFilter;
rating?: NumberFilterNullable;
state?: StringFilterNullable;
tagIds?: IdsFilter;
takenAt?: DateFilter;
trashedAt?: DateFilterNullable;
"type"?: EnumFilterAssetType;
updatedAt?: DateFilter;
visibility?: EnumFilterAssetVisibility;
};
export type SearchFilter = {
albumIds?: IdsFilter;
checksum?: StringFilter;
city?: StringFilterNullable;
country?: StringFilterNullable;
createdAt?: DateFilter;
description?: StringPatternFilter;
encodedVideoPath?: StringFilter;
fileSizeInBytes?: NumberFilter;
hasAlbums?: BoolFilter;
hasPeople?: BoolFilter;
hasTags?: BoolFilter;
id?: IdFilter;
isEncoded?: BoolFilter;
isFavorite?: BoolFilter;
isMotion?: BoolFilter;
isOffline?: BoolFilter;
lensModel?: StringFilterNullable;
libraryId?: IdFilterNullable;
make?: StringFilterNullable;
model?: StringFilterNullable;
ocr?: StringSimilarityFilter;
or?: SearchFilterBranch[];
originalFileName?: StringPatternFilter;
originalPath?: StringPatternFilter;
personIds?: IdsFilter;
rating?: NumberFilterNullable;
state?: StringFilterNullable;
tagIds?: IdsFilter;
takenAt?: DateFilter;
trashedAt?: DateFilterNullable;
"type"?: EnumFilterAssetType;
updatedAt?: DateFilter;
visibility?: EnumFilterAssetVisibility;
};
export type SearchOrder = {
direction?: AssetOrder;
field?: SearchOrderField;
};
export type MetadataSearchDto = {
/** Filter by album IDs */
albumIds?: string[];
@ -2167,10 +2329,13 @@ export type MetadataSearchDto = {
createdAfter?: string;
/** Filter by creation date (before) */
createdBefore?: string;
/** Cursor for the next page of results */
cursor?: string;
/** Filter by description text */
description?: string;
/** Filter by encoded video file path */
encodedVideoPath?: string;
filter?: SearchFilter;
/** Filter by asset ID */
id?: string;
/** Filter by encoded status */
@ -2195,6 +2360,7 @@ export type MetadataSearchDto = {
ocr?: string;
/** Sort order */
order?: AssetOrder;
orderBy?: SearchOrder;
/** Filter by original file name */
originalFileName?: string;
/** Filter by original file path */
@ -2262,6 +2428,8 @@ export type SearchAssetResponseDto = {
count: number;
facets: SearchFacetResponseDto[];
items: AssetResponseDto[];
/** Cursor for the next page of results */
nextCursor: string | null;
/** Next page token */
nextPage: string | null;
/** Total number of matching assets */
@ -2294,6 +2462,7 @@ export type RandomSearchDto = {
createdAfter?: string;
/** Filter by creation date (before) */
createdBefore?: string;
filter?: SearchFilter;
/** Filter by encoded status */
isEncoded?: boolean;
/** Filter by favorite status */
@ -2358,6 +2527,7 @@ export type SmartSearchDto = {
createdAfter?: string;
/** Filter by creation date (before) */
createdBefore?: string;
filter?: SearchFilter;
/** Filter by encoded status */
isEncoded?: boolean;
/** Filter by favorite status */
@ -2428,6 +2598,7 @@ export type StatisticsSearchDto = {
createdBefore?: string;
/** Filter by description text */
description?: string;
filter?: SearchFilter;
/** Filter by encoded status */
isEncoded?: boolean;
/** Filter by favorite status */
@ -8095,6 +8266,12 @@ export enum JobName {
IntegrityDeleteReportType = "IntegrityDeleteReportType",
IntegrityDeleteReports = "IntegrityDeleteReports"
}
export enum SearchOrderField {
FileCreatedAt = "fileCreatedAt",
LocalDateTime = "localDateTime",
FileSizeInBytes = "fileSizeInBytes",
Rating = "rating"
}
export enum SearchSuggestionType {
Country = "country",
State = "state",

View file

@ -115,6 +115,46 @@ describe(SearchController.name, () => {
);
});
it('should reject a deprecated field combined with a new structure field', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: {}, city: 'Oslo' });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['city'], message: 'Deprecated field city cannot be combined with filter' }]),
);
});
it('should reject an unknown key in the filter', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: { previewPath: { eq: 'preview.webp' } } });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['filter'], message: 'Unrecognized key: "previewPath"' }]),
);
});
it('should reject a nested or', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: { or: [{ or: [{ city: { eq: 'Oslo' } }] }] } });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['filter', 'or', 0], message: 'Unrecognized key: "or"' }]),
);
});
it('should reject an empty or branch', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ filter: { or: [{}] } });
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['filter', 'or', 0], message: 'At least one filter condition is required' }]),
);
});
describe('POST /search/random', () => {
it('should reject if withStacked is not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer())

View file

@ -69,7 +69,11 @@ export class SearchController {
@Endpoint({
summary: 'Search large assets',
description: 'Search for assets that are considered large based on specified criteria.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
history: new HistoryBuilder()
.added('v1')
.beta('v1')
.stable('v2')
.deprecated('v3.2.0', { replacementId: 'searchAssets' }),
})
searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchLargeAssets(auth, dto);

View file

@ -14,32 +14,40 @@ import {
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
import z from 'zod';
const ADDED_V3_2 = new HistoryBuilder().added('v3.2.0').getExtensions();
// fields deprecated in favor of the structured filter tree
const DEPRECATED_FLAT_FIELD = {
...new HistoryBuilder().added('v1').stable('v2').deprecated('v3.2.0').getExtensions(),
deprecated: true,
};
const BaseSearchSchema = z.object({
libraryId: z.uuidv4().nullish().describe('Library ID to filter by'),
type: AssetTypeSchema.optional(),
isEncoded: z.boolean().optional().describe('Filter by encoded status'),
isFavorite: z.boolean().optional().describe('Filter by favorite status'),
isMotion: z.boolean().optional().describe('Filter by motion photo status'),
isOffline: z.boolean().optional().describe('Filter by offline status'),
visibility: AssetVisibilitySchema.optional(),
createdBefore: isoDatetimeToDate.optional().describe('Filter by creation date (before)'),
createdAfter: isoDatetimeToDate.optional().describe('Filter by creation date (after)'),
updatedBefore: isoDatetimeToDate.optional().describe('Filter by update date (before)'),
updatedAfter: isoDatetimeToDate.optional().describe('Filter by update date (after)'),
trashedBefore: isoDatetimeToDate.optional().describe('Filter by trash date (before)'),
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
city: z.string().nullable().optional().describe('Filter by city name'),
state: z.string().nullable().optional().describe('Filter by state/province name'),
country: z.string().nullable().optional().describe('Filter by country name'),
make: z.string().nullable().optional().describe('Filter by camera make'),
model: z.string().nullable().optional().describe('Filter by camera model'),
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
libraryId: z.uuidv4().nullish().describe('Library ID to filter by').meta(DEPRECATED_FLAT_FIELD),
type: AssetTypeSchema.optional().meta(DEPRECATED_FLAT_FIELD),
isEncoded: z.boolean().optional().describe('Filter by encoded status').meta(DEPRECATED_FLAT_FIELD),
isFavorite: z.boolean().optional().describe('Filter by favorite status').meta(DEPRECATED_FLAT_FIELD),
isMotion: z.boolean().optional().describe('Filter by motion photo status').meta(DEPRECATED_FLAT_FIELD),
isOffline: z.boolean().optional().describe('Filter by offline status').meta(DEPRECATED_FLAT_FIELD),
visibility: AssetVisibilitySchema.optional().meta(DEPRECATED_FLAT_FIELD),
createdBefore: isoDatetimeToDate.optional().describe('Filter by creation date (before)').meta(DEPRECATED_FLAT_FIELD),
createdAfter: isoDatetimeToDate.optional().describe('Filter by creation date (after)').meta(DEPRECATED_FLAT_FIELD),
updatedBefore: isoDatetimeToDate.optional().describe('Filter by update date (before)').meta(DEPRECATED_FLAT_FIELD),
updatedAfter: isoDatetimeToDate.optional().describe('Filter by update date (after)').meta(DEPRECATED_FLAT_FIELD),
trashedBefore: isoDatetimeToDate.optional().describe('Filter by trash date (before)').meta(DEPRECATED_FLAT_FIELD),
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)').meta(DEPRECATED_FLAT_FIELD),
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)').meta(DEPRECATED_FLAT_FIELD),
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)').meta(DEPRECATED_FLAT_FIELD),
city: z.string().nullable().optional().describe('Filter by city name').meta(DEPRECATED_FLAT_FIELD),
state: z.string().nullable().optional().describe('Filter by state/province name').meta(DEPRECATED_FLAT_FIELD),
country: z.string().nullable().optional().describe('Filter by country name').meta(DEPRECATED_FLAT_FIELD),
make: z.string().nullable().optional().describe('Filter by camera make').meta(DEPRECATED_FLAT_FIELD),
model: z.string().nullable().optional().describe('Filter by camera model').meta(DEPRECATED_FLAT_FIELD),
lensModel: z.string().nullable().optional().describe('Filter by lens model').meta(DEPRECATED_FLAT_FIELD),
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album').meta(DEPRECATED_FLAT_FIELD),
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs').meta(DEPRECATED_FLAT_FIELD),
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs').meta(DEPRECATED_FLAT_FIELD),
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs').meta(DEPRECATED_FLAT_FIELD),
rating: z
.int()
.min(1)
@ -52,51 +60,24 @@ const BaseSearchSchema = z.object({
.stable('v2')
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
.updated('v3', 'Using -1 as a rating is no longer valid.')
.deprecated('v3.2.0')
.getExtensions(),
deprecated: true,
}),
ocr: z.string().optional().describe('Filter by OCR text content'),
ocr: z.string().optional().describe('Filter by OCR text content').meta(DEPRECATED_FLAT_FIELD),
});
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
withDeleted: z.boolean().optional().describe('Include deleted assets'),
withDeleted: z.boolean().optional().describe('Include deleted assets').meta(DEPRECATED_FLAT_FIELD),
withExif: z.boolean().optional().describe('Include EXIF data in response'),
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
size: z.int().min(1).max(1000).default(250).describe('Number of results to return'),
});
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
withStacked: z.boolean().optional().describe('Include stacked assets'),
withPeople: z.boolean().optional().describe('Include people data in response'),
}).meta({ id: 'RandomSearchDto' });
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
size: z.coerce.number().int().min(1).max(1000).default(250).describe('Number of results to return'),
}).meta({ id: 'LargeAssetSearchDto' });
const MetadataSearchSchema = RandomSearchSchema.extend({
id: z.uuidv4().optional().describe('Filter by asset ID'),
description: z.string().trim().optional().describe('Filter by description text'),
checksum: z.string().optional().describe('Filter by file checksum'),
originalFileName: z.string().trim().optional().describe('Filter by original file name'),
originalPath: z.string().optional().describe('Filter by original file path'),
previewPath: z.string().optional().describe('Filter by preview file path'),
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
page: z.int().min(1).optional().describe('Page number'),
}).meta({ id: 'MetadataSearchDto' });
const StatisticsSearchSchema = BaseSearchSchema.extend({
description: z.string().trim().optional().describe('Filter by description text'),
}).meta({ id: 'StatisticsSearchDto' });
const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
query: z.string().trim().optional().describe('Natural language search query'),
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({ id: 'SmartSearchDto' });
const SearchPlacesSchema = z
.object({
name: z.string().describe('Place name to search for'),
@ -263,47 +244,56 @@ export const SearchOrderSchema = z
})
.meta({ id: 'SearchOrder' });
const searchFilterBranchShape = {
id: IdFilterSchema,
libraryId: IdFilterNullableSchema,
type: EnumFilterAssetTypeSchema,
visibility: EnumFilterAssetVisibilitySchema,
isFavorite: BoolFilterSchema,
isMotion: BoolFilterSchema,
isOffline: BoolFilterSchema,
isEncoded: BoolFilterSchema,
hasAlbums: BoolFilterSchema,
hasPeople: BoolFilterSchema,
hasTags: BoolFilterSchema,
city: StringFilterNullableSchema,
state: StringFilterNullableSchema,
country: StringFilterNullableSchema,
make: StringFilterNullableSchema,
model: StringFilterNullableSchema,
lensModel: StringFilterNullableSchema,
description: StringPatternFilterSchema,
originalFileName: StringPatternFilterSchema,
originalPath: StringPatternFilterSchema,
ocr: StringSimilarityFilterSchema,
rating: NumberFilterNullableSchema,
fileSizeInBytes: NumberFilterSchema,
takenAt: DateFilterSchema,
createdAt: DateFilterSchema,
updatedAt: DateFilterSchema,
trashedAt: DateFilterNullableSchema,
personIds: IdsFilterSchema,
tagIds: IdsFilterSchema,
albumIds: IdsFilterSchema,
checksum: StringFilterSchema,
encodedVideoPath: StringFilterSchema,
};
const SearchFilterBranchSchema = z
.object({
id: IdFilterSchema,
libraryId: IdFilterNullableSchema,
type: EnumFilterAssetTypeSchema,
visibility: EnumFilterAssetVisibilitySchema,
isFavorite: BoolFilterSchema,
isMotion: BoolFilterSchema,
isOffline: BoolFilterSchema,
isEncoded: BoolFilterSchema,
hasAlbums: BoolFilterSchema,
hasPeople: BoolFilterSchema,
hasTags: BoolFilterSchema,
city: StringFilterNullableSchema,
state: StringFilterNullableSchema,
country: StringFilterNullableSchema,
make: StringFilterNullableSchema,
model: StringFilterNullableSchema,
lensModel: StringFilterNullableSchema,
description: StringPatternFilterSchema,
originalFileName: StringPatternFilterSchema,
originalPath: StringPatternFilterSchema,
ocr: StringSimilarityFilterSchema,
rating: NumberFilterNullableSchema,
fileSizeInBytes: NumberFilterSchema,
takenAt: DateFilterSchema,
createdAt: DateFilterSchema,
updatedAt: DateFilterSchema,
trashedAt: DateFilterNullableSchema,
personIds: IdsFilterSchema,
tagIds: IdsFilterSchema,
albumIds: IdsFilterSchema,
checksum: StringFilterSchema,
encodedVideoPath: StringFilterSchema,
})
.strictObject(searchFilterBranchShape)
.partial()
.refine((branch) => Object.values(branch).some((value) => value !== undefined), {
message: 'At least one filter condition is required',
})
.meta({ id: 'SearchFilterBranch' });
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
or: z.array(SearchFilterBranchSchema).min(1).optional(),
}).meta({ id: 'SearchFilter' });
export const SearchFilterSchema = z
.strictObject(searchFilterBranchShape)
.partial()
.extend({
or: z.array(SearchFilterBranchSchema).min(1).optional(),
})
.meta({ id: 'SearchFilter' });
export type IdFilter = z.infer<typeof IdFilterSchema>;
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
@ -319,6 +309,95 @@ 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);
/** Whether every asset the branch can match is provably inside an (access-checked) album */
export const isAlbumConfined = (branch: SearchFilterBranch): boolean =>
branch.albumIds?.any !== undefined || branch.albumIds?.all !== undefined;
/** Whether every result of the whole filter is album-confined */
export const isFullyAlbumConfined = (filter: SearchFilter): boolean =>
isAlbumConfined(filter) || (!!filter.or?.length && filter.or.every((branch) => isAlbumConfined(branch)));
/**
* 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) => {
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 of deprecatedFields) {
if (values[field] === undefined) {
continue;
}
ctx.addIssue({
code: 'custom',
path: [field],
message: `Deprecated field ${field} cannot be combined with ${newShapeFields.join('/')}`,
});
}
});
};
const filterField = SearchFilterSchema.optional().meta(ADDED_V3_2);
const cursorField = z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_2);
const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({
withStacked: z.boolean().optional().describe('Include stacked assets'),
withPeople: z.boolean().optional().describe('Include people data in response'),
filter: filterField,
});
const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' });
const MetadataSearchSchema = withShapeExclusivity(
RandomSearchBaseSchema.extend({
id: z.uuidv4().optional().describe('Filter by asset ID').meta(DEPRECATED_FLAT_FIELD),
description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD),
checksum: z.string().optional().describe('Filter by file checksum').meta(DEPRECATED_FLAT_FIELD),
originalFileName: z.string().trim().optional().describe('Filter by original file name').meta(DEPRECATED_FLAT_FIELD),
originalPath: z.string().optional().describe('Filter by original file path').meta(DEPRECATED_FLAT_FIELD),
previewPath: z.string().optional().describe('Filter by preview file path').meta(DEPRECATED_FLAT_FIELD),
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path').meta(DEPRECATED_FLAT_FIELD),
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path').meta(DEPRECATED_FLAT_FIELD),
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_2),
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: filterField,
}),
).meta({ id: 'StatisticsSearchDto' });
const SmartSearchSchema = withShapeExclusivity(
BaseSearchWithResultsSchema.extend({
size: z.int().min(1).max(1000).default(100).describe('Number of results to return'),
query: z.string().trim().optional().describe('Natural language search query'),
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: filterField,
}),
).meta({ id: 'SmartSearchDto' });
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
@ -372,7 +451,8 @@ const SearchAssetResponseSchema = z
count: z.int().min(0).describe('Number of assets in this page'),
items: z.array(AssetResponseSchema),
facets: z.array(SearchFacetResponseSchema),
nextPage: z.string().nullable().describe('Next page token'),
nextPage: z.string().nullable().describe('Next page token').meta(DEPRECATED_FLAT_FIELD),
nextCursor: z.string().nullable().describe('Cursor for the next page of results').meta(ADDED_V3_2),
})
.meta({ id: 'SearchAssetResponseDto' });

View file

@ -455,14 +455,20 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and true
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$2
$4
offset
$5
-- SearchRepository.searchMetadataV3 (empty)
-- SearchRepository.searchMetadataV3 (or-mixed-scope)
select
"asset"."id",
"asset"."updateId",
@ -496,12 +502,31 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
true
(
"asset"."visibility" != $1
or "asset"."ownerId" = $2
)
and (
exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($3::uuid[])
)
or (
"asset_exif"."city" = $4
and "asset"."ownerId" = any ($5::uuid[])
)
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$1
$6
offset
$7
-- SearchRepository.searchMetadataV3 (or-exif-only)
select
@ -538,12 +563,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and "asset_exif"."city" = $2
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and "asset_exif"."city" = $4
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (string-eq-null)
select
@ -580,12 +611,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and "asset_exif"."city" is null
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$2
$4
offset
$5
-- SearchRepository.searchMetadataV3 (string-pattern-like)
select
@ -622,12 +659,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%')
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($4) || '%')
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (string-pattern-notLike)
select
@ -664,12 +707,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%')
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($4) || '%')
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (string-pattern-startsWith)
select
@ -706,12 +755,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%')
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($4) || '%')
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (string-similarity-ocr)
select
@ -748,19 +803,25 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and exists (
select
from
"ocr_search"
where
"ocr_search"."assetId" = "asset"."id"
and f_unaccent (ocr_search.text) %>> f_unaccent ($2)
and f_unaccent (ocr_search.text) %>> f_unaccent ($4)
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (ids-any)
select
@ -796,20 +857,25 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
(
"asset"."visibility" != $1
or "asset"."ownerId" = $2
)
and exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($2::uuid[])
and "album_asset"."albumId" = any ($3::uuid[])
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$4
offset
$5
-- SearchRepository.searchMetadataV3 (ids-all)
select
@ -846,6 +912,10 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and exists (
select
"asset_face"."assetId"
@ -854,18 +924,20 @@ where
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and "asset_face"."personGroupId" = any ($3::uuid[])
and "asset_face"."isVisible" = $4
and "asset_face"."personGroupId" = any ($5::uuid[])
group by
"asset_face"."assetId"
having
count(distinct "asset_face"."personGroupId") = $4
count(distinct "asset_face"."personGroupId") = $6
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$5
$7
offset
$8
-- SearchRepository.searchMetadataV3 (ids-all-single)
select
@ -901,20 +973,25 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
(
"asset"."visibility" != $1
or "asset"."ownerId" = $2
)
and exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($2::uuid[])
and "album_asset"."albumId" = any ($3::uuid[])
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$4
offset
$5
-- SearchRepository.searchMetadataV3 (ids-none)
select
@ -951,6 +1028,10 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and not exists (
select
from
@ -958,13 +1039,15 @@ where
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
where
"tag_asset"."assetId" = "asset"."id"
and "tag_closure"."id_ancestor" = any ($2::uuid[])
and "tag_closure"."id_ancestor" = any ($4::uuid[])
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (ids-tags-all)
select
@ -1001,6 +1084,10 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and exists (
select
"tag_asset"."assetId"
@ -1009,17 +1096,19 @@ where
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
where
"tag_asset"."assetId" = "asset"."id"
and "tag_closure"."id_ancestor" = any ($2::uuid[])
and "tag_closure"."id_ancestor" = any ($4::uuid[])
group by
"tag_asset"."assetId"
having
count(distinct "tag_closure"."id_ancestor") = $3
count(distinct "tag_closure"."id_ancestor") = $5
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$4
$6
offset
$7
-- SearchRepository.searchMetadataV3 (has-albums-false)
select
@ -1056,6 +1145,10 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and not exists (
select
from
@ -1067,7 +1160,9 @@ order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$2
$4
offset
$5
-- SearchRepository.searchMetadataV3 (is-encoded)
select
@ -1104,19 +1199,25 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and exists (
select
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $2
and "asset_file"."type" = $4
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (number-range)
select
@ -1154,14 +1255,20 @@ from
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset_exif"."fileSizeInByte" <= $2
and "asset_exif"."fileSizeInByte" >= $3
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and (
"asset_exif"."fileSizeInByte" <= $4
and "asset_exif"."fileSizeInByte" >= $5
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$4
$6
offset
$7
-- SearchRepository.searchMetadataV3 (date-eq)
select
@ -1198,12 +1305,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and "asset"."fileCreatedAt" = $2
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and "asset"."fileCreatedAt" = $4
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$6
-- SearchRepository.searchMetadataV3 (date-range)
select
@ -1241,14 +1354,20 @@ from
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."fileCreatedAt" < $2
and "asset"."fileCreatedAt" >= $3
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and (
"asset"."fileCreatedAt" < $4
and "asset"."fileCreatedAt" >= $5
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$4
$6
offset
$7
-- SearchRepository.searchMetadataV3 (order-fileSize-noExif)
select
@ -1285,12 +1404,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and true
order by
"asset_exif"."fileSizeInByte" desc nulls last,
"asset"."id" desc
limit
$2
$4
offset
$5
-- SearchRepository.searchMetadataV3 (order-rating-withExif)
select
@ -1328,12 +1453,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and true
order by
"asset_exif"."rating" asc nulls last,
"asset"."id" asc
limit
$2
$4
offset
$5
-- SearchRepository.searchMetadataV3 (or-branches)
select
@ -1371,7 +1502,11 @@ from
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."isFavorite" = $2
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and (
"asset"."isFavorite" = $4
or exists (
select
from
@ -1379,15 +1514,17 @@ where
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3
and "asset_face"."personGroupId" = any ($4::uuid[])
and "asset_face"."isVisible" = $5
and "asset_face"."personGroupId" = any ($6::uuid[])
)
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$5
$7
offset
$8
-- SearchRepository.searchMetadataV3 (or-with-top-level)
select
@ -1423,19 +1560,25 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
(
"asset"."visibility" != $1
or "asset"."ownerId" = $2
)
and (
"asset"."fileCreatedAt" < $2
and "asset"."fileCreatedAt" >= $3
"asset"."fileCreatedAt" < $3
and "asset"."fileCreatedAt" >= $4
and (
"asset"."isFavorite" = $4
(
"asset"."isFavorite" = $5
and "asset"."ownerId" = any ($6::uuid[])
)
or exists (
select
from
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($5::uuid[])
and "album_asset"."albumId" = any ($7::uuid[])
)
)
)
@ -1443,8 +1586,310 @@ order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$8
offset
$9
-- SearchRepository.searchMetadataV3 (cursor-offset)
select
"asset"."id",
"asset"."updateId",
"asset"."createdAt",
"asset"."updatedAt",
"asset"."deletedAt",
"asset"."status",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."duplicateId",
"asset"."duration",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
"asset"."isFavorite",
"asset"."isOffline",
"asset"."isEdited",
"asset"."visibility",
"asset"."libraryId",
"asset"."livePhotoVideoId",
"asset"."localDateTime",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."stackId",
"asset"."thumbhash",
"asset"."type",
"asset"."width",
"asset"."height"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and "asset"."isFavorite" = $4
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$5
offset
$6
-- SearchRepository.searchRandomV3 (baseline)
select
"asset"."id",
"asset"."updateId",
"asset"."createdAt",
"asset"."updatedAt",
"asset"."deletedAt",
"asset"."status",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."duplicateId",
"asset"."duration",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
"asset"."isFavorite",
"asset"."isOffline",
"asset"."isEdited",
"asset"."visibility",
"asset"."libraryId",
"asset"."livePhotoVideoId",
"asset"."localDateTime",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."stackId",
"asset"."thumbhash",
"asset"."type",
"asset"."width",
"asset"."height"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and true
order by
random()
limit
$4
-- SearchRepository.searchRandomV3 (with-filter)
select
"asset"."id",
"asset"."updateId",
"asset"."createdAt",
"asset"."updatedAt",
"asset"."deletedAt",
"asset"."status",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."duplicateId",
"asset"."duration",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
"asset"."isFavorite",
"asset"."isOffline",
"asset"."isEdited",
"asset"."visibility",
"asset"."libraryId",
"asset"."livePhotoVideoId",
"asset"."localDateTime",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."stackId",
"asset"."thumbhash",
"asset"."type",
"asset"."width",
"asset"."height"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and "asset"."isFavorite" = $4
order by
random()
limit
$5
-- SearchRepository.searchSmartV3 (baseline)
begin
set
local vchordrq.probes = 1
select
"asset"."id",
"asset"."updateId",
"asset"."createdAt",
"asset"."updatedAt",
"asset"."deletedAt",
"asset"."status",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."duplicateId",
"asset"."duration",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
"asset"."isFavorite",
"asset"."isOffline",
"asset"."isEdited",
"asset"."visibility",
"asset"."libraryId",
"asset"."livePhotoVideoId",
"asset"."localDateTime",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."stackId",
"asset"."thumbhash",
"asset"."type",
"asset"."width",
"asset"."height"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and true
order by
smart_search.embedding <=> $4,
"asset"."id" asc
limit
$5
offset
$6
commit
-- SearchRepository.searchSmartV3 (with-filter)
begin
set
local vchordrq.probes = 1
select
"asset"."id",
"asset"."updateId",
"asset"."createdAt",
"asset"."updatedAt",
"asset"."deletedAt",
"asset"."status",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."duplicateId",
"asset"."duration",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
"asset"."isFavorite",
"asset"."isOffline",
"asset"."isEdited",
"asset"."visibility",
"asset"."libraryId",
"asset"."livePhotoVideoId",
"asset"."localDateTime",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."stackId",
"asset"."thumbhash",
"asset"."type",
"asset"."width",
"asset"."height"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and (
"asset"."fileCreatedAt" < $4
and "asset"."fileCreatedAt" >= $5
)
order by
smart_search.embedding <=> $6,
"asset"."id" asc
limit
$7
offset
$8
commit
-- SearchRepository.searchSmartV3 (cursor-offset)
begin
set
local vchordrq.probes = 1
select
"asset"."id",
"asset"."updateId",
"asset"."createdAt",
"asset"."updatedAt",
"asset"."deletedAt",
"asset"."status",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."duplicateId",
"asset"."duration",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
"asset"."isFavorite",
"asset"."isOffline",
"asset"."isEdited",
"asset"."visibility",
"asset"."libraryId",
"asset"."livePhotoVideoId",
"asset"."localDateTime",
"asset"."originalFileName",
"asset"."originalPath",
"asset"."ownerId",
"asset"."stackId",
"asset"."thumbhash",
"asset"."type",
"asset"."width",
"asset"."height"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and true
order by
smart_search.embedding <=> $4,
"asset"."id" asc
limit
$5
offset
$6
commit
-- SearchRepository.searchStatisticsV3 (baseline)
select
count(*) as "total"
@ -1453,6 +1898,10 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and true
-- SearchRepository.searchStatisticsV3 (with-filter)
@ -1464,9 +1913,13 @@ from
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset_exif"."fileSizeInByte" >= $2
and "asset"."fileCreatedAt" < $3
and "asset"."fileCreatedAt" >= $4
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and (
"asset_exif"."fileSizeInByte" >= $4
and "asset"."fileCreatedAt" < $5
and "asset"."fileCreatedAt" >= $6
)
-- SearchRepository.searchStatisticsV3 (with-or)
@ -1478,7 +1931,11 @@ from
where
"asset"."ownerId" = any ($1::uuid[])
and (
"asset"."isFavorite" = $2
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and (
"asset"."isFavorite" = $4
or not exists (
select
from

View file

@ -14,11 +14,13 @@ import {
searchAssetBuilder,
searchAssetBuilderLegacy,
searchMetadataV3Examples,
searchRandomV3Examples,
searchSmartV3Examples,
searchStatisticsV3Examples,
withExifInner,
withSearchOrder,
} from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
import z from 'zod';
export interface SearchAssetIdOptions {
@ -135,12 +137,15 @@ export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
export interface AssetSearchBuilderV3Options {
filter?: SearchFilter;
/** Server-derived ownership scope. Never client-controlled. */
userIds?: string[];
export interface AssetSearchScope {
userIds: string[];
lockedOwnerId: string;
/** whose version of the people to select, required when selecting faces or people */
viewingUserId?: string;
}
export interface AssetSearchBuilderV3Options {
filter?: SearchFilter;
withExif?: boolean;
withFaces?: boolean;
withPeople?: boolean;
@ -148,10 +153,6 @@ export interface AssetSearchBuilderV3Options {
order?: SearchOrder;
}
export interface AssetSearchPaginationV3Options {
size: number;
}
export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
@ -211,6 +212,7 @@ export interface GetCameraLensModelsOptions {
export class SearchRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
// TODO(v4): remove with the deprecated flat-field search API
@GenerateSql({
params: [
{ page: 1, size: 100 },
@ -236,6 +238,7 @@ export class SearchRepository {
return paginationHelper(items, pagination.size);
}
// TODO(v4): remove with the deprecated flat-field search API
@GenerateSql({
params: [
{
@ -252,6 +255,7 @@ export class SearchRepository {
.executeTakeFirstOrThrow();
}
// TODO(v4): remove with the deprecated flat-field search API
@GenerateSql({
params: [
100,
@ -272,6 +276,7 @@ export class SearchRepository {
.execute();
}
// TODO(v4): remove with the deprecated flat-field search API
@GenerateSql({
params: [
100,
@ -295,6 +300,7 @@ export class SearchRepository {
.execute();
}
// TODO(v4): remove with the deprecated flat-field search API
@GenerateSql({
params: [
{ page: 1, size: 200 },
@ -530,20 +536,56 @@ export class SearchRepository {
return res.map((row) => row.lensModel!);
}
// TODO(v4): drop the V3 suffix once the legacy methods are removed
@GenerateSql(...searchMetadataV3Examples)
searchMetadataV3(
pagination: AssetSearchPaginationV3Options,
options: AssetSearchBuilderV3Options,
): Promise<MapAsset[]> {
return withSearchOrder(searchAssetBuilder(this.db, options), options.order)
async searchMetadataV3(pagination: PaginationOptions, options: AssetSearchBuilderV3Options, scope: AssetSearchScope) {
const items = await withSearchOrder(searchAssetBuilder(this.db, options, scope), options.order)
.select(columns.searchAsset)
.limit(pagination.size)
.limit(pagination.take + 1)
.offset(pagination.skip ?? 0)
.execute();
return paginationHelper(items, pagination.take);
}
// TODO(v4): drop the V3 suffix once the legacy methods are removed
@GenerateSql(...searchRandomV3Examples)
searchRandomV3(
size: number,
options: Omit<AssetSearchBuilderV3Options, 'order'>,
scope: AssetSearchScope,
): Promise<MapAsset[]> {
return searchAssetBuilder(this.db, options, scope)
.select(columns.searchAsset)
.orderBy(sql`random()`)
.limit(size)
.execute();
}
// TODO(v4): drop the V3 suffix once the legacy methods are removed
@GenerateSql(...searchSmartV3Examples)
searchSmartV3(
pagination: PaginationOptions,
options: Omit<AssetSearchBuilderV3Options, 'order'> & { embedding: string },
scope: AssetSearchScope,
) {
return this.db.transaction().execute(async (trx) => {
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx);
const items = await searchAssetBuilder(trx, options, scope)
.select(columns.searchAsset)
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
.orderBy('asset.id', 'asc')
.limit(pagination.take + 1)
.offset(pagination.skip ?? 0)
.execute();
return paginationHelper(items, pagination.take);
});
}
// TODO(v4): drop the V3 suffix once the legacy methods are removed
@GenerateSql(...searchStatisticsV3Examples)
searchStatisticsV3(options: AssetSearchBuilderV3Options) {
return searchAssetBuilder(this.db, options)
searchStatisticsV3(options: AssetSearchBuilderV3Options, scope: AssetSearchScope) {
return searchAssetBuilder(this.db, options, scope)
.select((qb) => qb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
}

View file

@ -1,11 +1,13 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AssetVisibility } from 'src/enum';
import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest';
@ -215,6 +217,84 @@ describe(SearchService.name, () => {
});
});
describe('new shape routing', () => {
it('should route a filter request to the V3 search and a flat request to the legacy search', async () => {
const auth = AuthFactory.create();
mocks.search.searchMetadataV3.mockResolvedValue({ hasNextPage: false, items: [] });
await sut.searchMetadata(auth, { size: 250, filter: {} });
expect(mocks.search.searchMetadataV3).toHaveBeenCalled();
expect(mocks.search.searchMetadata).not.toHaveBeenCalled();
mocks.search.searchMetadata.mockResolvedValue({ hasNextPage: false, items: [] });
await sut.searchMetadata(auth, { size: 250, city: 'Oslo' });
expect(mocks.search.searchMetadata).toHaveBeenCalled();
});
it('should route statistics, random, and smart filter requests to their V3 search', async () => {
const auth = AuthFactory.create();
mocks.search.searchStatisticsV3.mockResolvedValue({ total: 0 });
await expect(sut.searchStatistics(auth, { filter: {} })).resolves.toEqual({ total: 0 });
mocks.search.searchRandomV3.mockResolvedValue([]);
await expect(sut.searchRandom(auth, { size: 250, filter: {} })).resolves.toEqual([]);
mocks.search.searchSmartV3.mockResolvedValue({ hasNextPage: false, items: [] });
mocks.machineLearning.encodeText.mockResolvedValue('[1, 2, 3]');
await sut.searchSmart(auth, { size: 100, filter: {}, query: 'test' });
expect(mocks.search.searchSmartV3).toHaveBeenCalledWith(
{ take: 100 },
expect.objectContaining({ embedding: '[1, 2, 3]' }),
expect.objectContaining({ lockedOwnerId: expect.any(String) }),
);
});
it('should reject an invalid cursor', async () => {
await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, cursor: '???' })).rejects.toThrowError(
new BadRequestException('Invalid cursor'),
);
});
it('should reject an unelevated session whose filter could match locked assets', async () => {
const filter = { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } };
await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, filter })).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('should reject a shared link whose filter is not confined to albums everywhere', async () => {
const auth = AuthFactory.from().sharedLink().build();
const albumId = newUuid();
await expect(sut.searchMetadata(auth, { size: 250, filter: {} })).rejects.toThrowError(
new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'),
);
await expect(
sut.searchMetadata(auth, {
size: 250,
filter: { or: [{ albumIds: { any: [albumId] } }, { city: { eq: 'Oslo' } }] },
}),
).rejects.toThrowError(
new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'),
);
});
it('should allow a shared link when every branch is confined to a covered album', async () => {
const auth = AuthFactory.from().sharedLink().build();
const albumId = newUuid();
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([albumId]));
mocks.search.searchMetadataV3.mockResolvedValue({ hasNextPage: false, items: [] });
await expect(
sut.searchMetadata(auth, { size: 250, filter: { or: [{ albumIds: { any: [albumId] } }] } }),
).resolves.toBeDefined();
expect(mocks.search.searchMetadataV3).toHaveBeenCalled();
});
});
describe('searchSmart', () => {
beforeEach(() => {
mocks.search.searchSmart.mockResolvedValue({ hasNextPage: false, items: [] });
@ -226,7 +306,7 @@ describe(SearchService.name, () => {
machineLearning: { enabled: false },
});
await expect(sut.searchSmart(authStub.user1, { query: 'test' })).rejects.toThrowError(
await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError(
new BadRequestException('Smart search is not enabled'),
);
});
@ -236,13 +316,13 @@ describe(SearchService.name, () => {
machineLearning: { clip: { enabled: false } },
});
await expect(sut.searchSmart(authStub.user1, { query: 'test' })).rejects.toThrowError(
await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError(
new BadRequestException('Smart search is not enabled'),
);
});
it('should work', async () => {
await sut.searchSmart(authStub.user1, { query: 'test' });
await sut.searchSmart(authStub.user1, { size: 100, query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
'test',
@ -252,6 +332,7 @@ describe(SearchService.name, () => {
{ page: 1, size: 100 },
{
query: 'test',
size: 100,
embedding: '[1, 2, 3]',
userIds: [authStub.user1.user.id],
viewingUserId: authStub.user1.user.id,
@ -278,7 +359,7 @@ describe(SearchService.name, () => {
machineLearning: { clip: { modelName: 'ViT-B-16-SigLIP__webli' } },
});
await sut.searchSmart(authStub.user1, { query: 'test' });
await sut.searchSmart(authStub.user1, { size: 100, query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
'test',
@ -287,7 +368,7 @@ describe(SearchService.name, () => {
});
it('should use language specified in request', async () => {
await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' });
await sut.searchSmart(authStub.user1, { size: 100, query: 'test', language: 'de' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
'test',

View file

@ -2,13 +2,17 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { LRUMap } from 'mnemonist';
import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { SystemConfig } from 'src/dtos/config.dto';
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import {
isFullyAlbumConfined,
isNewShapeRequest,
LargeAssetSearchDto,
mapPlaces,
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchFilter,
SearchPeopleDto,
SearchPlacesDto,
SearchResponseDto,
@ -19,10 +23,13 @@ import {
StatisticsSearchDto,
} from 'src/dtos/search.dto';
import { AssetOrder, AssetVisibility, Permission } from 'src/enum';
import { AssetSearchScope } from 'src/repositories/search.repository';
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 } from 'src/utils/search-filter';
@Injectable()
export class SearchService extends BaseService {
@ -65,6 +72,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);
}
@ -86,7 +97,7 @@ export class SearchService extends BaseService {
}
const page = dto.page ?? 1;
const size = dto.size || 250;
const size = dto.size;
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
@ -99,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);
@ -117,12 +132,16 @@ 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);
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, {
const items = await this.searchRepository.searchRandom(dto.size, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
@ -137,7 +156,7 @@ export class SearchService extends BaseService {
}
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
const items = await this.searchRepository.searchLargeAssets(dto.size, {
...dto,
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
userIds,
@ -147,6 +166,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);
}
@ -157,30 +180,9 @@ 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 size = dto.size;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size },
{
@ -192,7 +194,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[]> {
@ -236,6 +238,121 @@ export class SearchService extends BaseService {
}
}
private async searchMetadataV3(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
const { filter, scope } = await this.resolveSearchScopeV3(auth, dto);
const { offset } = decodeSearchCursor(dto.cursor);
const size = dto.size;
const { hasNextPage, items } = await this.searchRepository.searchMetadataV3(
{ take: size, skip: offset },
{
filter,
withExif: dto.withExif,
withPeople: dto.withPeople,
withStacked: dto.withStacked,
order: dto.orderBy,
},
scope,
);
return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null });
}
private async searchStatisticsV3(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
const { filter, scope } = await this.resolveSearchScopeV3(auth, dto);
return this.searchRepository.searchStatisticsV3({ filter }, scope);
}
private async searchRandomV3(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
const { filter, scope } = await this.resolveSearchScopeV3(auth, dto);
const items = await this.searchRepository.searchRandomV3(
dto.size,
{
filter,
withExif: dto.withExif,
withPeople: dto.withPeople,
withStacked: dto.withStacked,
},
scope,
);
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 [{ filter, scope }, embedding] = await Promise.all([
this.resolveSearchScopeV3(auth, dto),
this.resolveEmbedding(auth, dto, machineLearning),
]);
// no cursor until a rank-aware pagination strategy for smart search is decided
const { items } = await this.searchRepository.searchSmartV3(
{ take: dto.size },
{ filter, withExif: dto.withExif, embedding },
scope,
);
return this.mapResponse(items, { auth });
}
private async resolveSearchScopeV3(
auth: AuthDto,
dto: { filter?: SearchFilter },
): Promise<{ filter: SearchFilter; scope: AssetSearchScope }> {
const filter = dto.filter ?? {};
const effectiveFilter = applyLockedVisibilityPolicy(auth, filter);
const fullyConfined = isFullyAlbumConfined(filter);
// a shared link visitor does not have a universe, so there every branch must be confined
if (auth.sharedLink && !fullyConfined) {
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([
// a fully confined filter searches albums only, so the unused universe can skip the partner lookup
fullyConfined ? [auth.user.id] : this.getUserIdsToSearch(auth),
albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined,
]);
return { filter: effectiveFilter, scope: { userIds, lockedOwnerId: auth.user.id, viewingUserId: auth.user.id } };
}
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) {
@ -249,7 +366,11 @@ export class SearchService extends BaseService {
return [auth.user.id, ...partnerIds];
}
private mapResponse(assets: MapAsset[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto {
private mapResponse(
assets: MapAsset[],
options: AssetMapOptions,
page: { nextPage?: string | null; nextCursor?: string | null } = {},
): SearchResponseDto {
return {
albums: { total: 0, count: 0, items: [], facets: [] },
assets: {
@ -257,7 +378,8 @@ export class SearchService extends BaseService {
count: assets.length,
items: assets.map((asset) => mapAsset(asset, options)),
facets: [],
nextPage,
nextPage: page.nextPage ?? null,
nextCursor: page.nextCursor ?? null,
},
};
}

View file

@ -24,6 +24,7 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto';
import {
DEFAULT_SEARCH_ORDER,
IdsFilter,
isAlbumConfined,
SearchFilterBranch,
SearchOrder,
StringFilter,
@ -38,7 +39,11 @@ import {
ExifOrientation,
SearchOrderField,
} from 'src/enum';
import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository';
import {
AssetSearchBuilderOptions,
AssetSearchBuilderV3Options,
AssetSearchScope,
} from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
@ -787,8 +792,16 @@ function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch
// ordering is deliberately left to the caller so aggregate-only consumers (counts, stats)
// can compose the same filters without stripping an order by
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options) {
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options, scope: AssetSearchScope) {
const filter = options.filter ?? {};
const branches = filter.or ?? [];
const ownershipPredicate = (eb: AssetExpressionBuilder) => eb('asset.ownerId', '=', anyUuid(scope.userIds));
// search universe: own+partner assets unless album-confined, which searches the albums instead;
// ownership lands nowhere (top level confined), per unconfined branch, or hoisted globally
const topConfined = isAlbumConfined(filter);
const anyBranchConfined = branches.some((branch) => isAlbumConfined(branch));
const scopePerBranch = !topConfined && anyBranchConfined;
const scopeGlobally = !topConfined && !anyBranchConfined;
return (
kysely
@ -797,17 +810,27 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
// postgres eliminates the left join when no exif column is referenced, so unused joins are free
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.$if(!!options.withExif, (qb) => qb.select(selectExifInfo))
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
.$if(scopeGlobally, (qb) => qb.where(ownershipPredicate))
.where((eb) =>
eb.or([eb('asset.visibility', '!=', AssetVisibility.Locked), eb('asset.ownerId', '=', scope.lockedOwnerId)]),
)
.$if(!!(options.withFaces || options.withPeople), (qb) =>
qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })),
qb.select(withFacesAndPeople({ viewingUserId: scope.viewingUserId! })),
)
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
.where((eb) => {
const predicates = branchPredicates(eb, filter);
if (filter.or && filter.or.length > 0) {
predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch)))));
if (branches.length > 0) {
predicates.push(
eb.or(
branches.map((branch) =>
eb.and([
...branchPredicates(eb, branch),
...(scopePerBranch && !isAlbumConfined(branch) ? [ownershipPredicate(eb)] : []),
]),
),
),
);
}
return predicates.length > 0 ? eb.and(predicates) : eb.lit(true);
})
@ -836,160 +859,238 @@ export function withSearchOrder(qb: ReturnType<typeof searchAssetBuilder>, order
);
}
const scopeExample: AssetSearchScope = { userIds: [DummyValue.UUID], lockedOwnerId: DummyValue.UUID };
export const searchMetadataV3Examples: GenerateSqlQueries[] = [
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] },
{ name: 'empty', params: [{ size: 100 }, {}] },
{ name: 'baseline', params: [{ take: 100 }, {}, scopeExample] },
{
name: 'or-mixed-scope',
params: [
{ take: 100 },
{
filter: { or: [{ albumIds: { any: [DummyValue.UUID] } }, { city: { eq: DummyValue.STRING } }] },
},
scopeExample,
],
},
{
name: 'or-exif-only',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }],
params: [
{ take: 100 },
{
filter: { or: [{ city: { eq: DummyValue.STRING } }] },
},
scopeExample,
],
},
{
name: 'string-eq-null',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
params: [{ take: 100 }, { filter: { city: { eq: null } } }, scopeExample],
},
{
name: 'string-pattern-like',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }],
params: [
{ take: 100 },
{
filter: { description: { like: DummyValue.STRING } },
},
scopeExample,
],
},
{
name: 'string-pattern-notLike',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }],
params: [
{ take: 100 },
{
filter: { description: { notLike: DummyValue.STRING } },
},
scopeExample,
],
},
{
name: 'string-pattern-startsWith',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } },
{ take: 100 },
{
filter: { originalFileName: { startsWith: DummyValue.STRING } },
},
scopeExample,
],
},
{
name: 'string-similarity-ocr',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }],
params: [{ take: 100 }, { filter: { ocr: { matches: DummyValue.STRING } } }, scopeExample],
},
{
name: 'ids-any',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }],
params: [{ take: 100 }, { filter: { albumIds: { any: [DummyValue.UUID] } } }, scopeExample],
},
{
name: 'ids-all',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
{ take: 100 },
{
filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } },
},
scopeExample,
],
},
{
name: 'ids-all-single',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }],
params: [{ take: 100 }, { filter: { albumIds: { all: [DummyValue.UUID] } } }, scopeExample],
},
{
name: 'ids-none',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }],
params: [{ take: 100 }, { filter: { tagIds: { none: [DummyValue.UUID] } } }, scopeExample],
},
{
name: 'ids-tags-all',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
{ take: 100 },
{
filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } },
},
scopeExample,
],
},
{
name: 'has-albums-false',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }],
params: [{ take: 100 }, { filter: { hasAlbums: { eq: false } } }, scopeExample],
},
{
name: 'is-encoded',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }],
params: [{ take: 100 }, { filter: { isEncoded: { eq: true } } }, scopeExample],
},
{
name: 'number-range',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }],
params: [
{ take: 100 },
{
filter: { fileSizeInBytes: { gte: 100, lte: 1000 } },
},
scopeExample,
],
},
{
name: 'date-eq',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }],
params: [{ take: 100 }, { filter: { takenAt: { eq: DummyValue.DATE } } }, scopeExample],
},
{
name: 'date-range',
params: [
{ size: 100 },
{ take: 100 },
{
userIds: [DummyValue.UUID],
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
},
scopeExample,
],
},
{
name: 'order-fileSize-noExif',
params: [
{ size: 100 },
{ take: 100 },
{
userIds: [DummyValue.UUID],
order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc },
withExif: false,
},
scopeExample,
],
},
{
name: 'order-rating-withExif',
params: [
{ size: 100 },
{ take: 100 },
{
userIds: [DummyValue.UUID],
order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc },
withExif: true,
},
scopeExample,
],
},
{
name: 'or-branches',
params: [
{ size: 100 },
{ take: 100 },
{
userIds: [DummyValue.UUID],
filter: {
or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }],
},
},
scopeExample,
],
},
{
name: 'or-with-top-level',
params: [
{ size: 100 },
{ take: 100 },
{
userIds: [DummyValue.UUID],
filter: {
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }],
},
},
scopeExample,
],
},
{
name: 'cursor-offset',
params: [{ take: 100, skip: 100 }, { filter: { isFavorite: { eq: true } } }, scopeExample],
},
];
export const searchRandomV3Examples: GenerateSqlQueries[] = [
{ name: 'baseline', params: [100, {}, scopeExample] },
{
name: 'with-filter',
params: [100, { filter: { isFavorite: { eq: true } } }, scopeExample],
},
];
export const searchSmartV3Examples: GenerateSqlQueries[] = [
{
name: 'baseline',
params: [{ take: 100 }, { embedding: DummyValue.VECTOR }, scopeExample],
},
{
name: 'with-filter',
params: [
{ take: 100 },
{
embedding: DummyValue.VECTOR,
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
},
scopeExample,
],
},
{
name: 'cursor-offset',
params: [{ take: 100, skip: 100 }, { embedding: DummyValue.VECTOR }, scopeExample],
},
];
export const searchStatisticsV3Examples: GenerateSqlQueries[] = [
{ name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] },
{ name: 'baseline', params: [{}, scopeExample] },
{
name: 'with-filter',
params: [
{
userIds: [DummyValue.UUID],
filter: {
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
fileSizeInBytes: { gte: 100 },
},
},
scopeExample,
],
},
{
name: 'with-or',
params: [
{
userIds: [DummyValue.UUID],
filter: {
or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }],
},
},
scopeExample,
],
},
];

View file

@ -0,0 +1,38 @@
import { BadRequestException } from '@nestjs/common';
import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor';
import { describe, expect, it } from 'vitest';
describe('encodeSearchCursor', () => {
it('should produce an opaque base64url string', () => {
const cursor = encodeSearchCursor(250);
expect(cursor).toMatch(/^[\w-]+$/);
});
it('should round-trip an offset', () => {
for (const offset of [0, 1, 250, 1_000_000]) {
expect(decodeSearchCursor(encodeSearchCursor(offset))).toEqual({ offset });
}
});
});
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!!!'],
['base64 of non-JSON', Buffer.from('not json').toString('base64url')],
['JSON without an offset', encode({})],
['JSON with a non-object payload', encode(42)],
['null payload', encode(null)],
['negative offset', encode({ offset: -1 })],
['fractional offset', encode({ offset: 1.5 })],
['string offset', encode({ offset: '5' })],
])('should reject %s', (_, cursor) => {
expect(() => decodeSearchCursor(cursor)).toThrowError(new BadRequestException('Invalid cursor'));
});
});

View file

@ -0,0 +1,21 @@
import { BadRequestException } from '@nestjs/common';
import z from 'zod';
const SearchCursorPayloadSchema = z.object({
offset: z.int().min(0),
});
export const encodeSearchCursor = (offset: number): string =>
Buffer.from(JSON.stringify({ offset } satisfies z.infer<typeof SearchCursorPayloadSchema>)).toString('base64url');
export const decodeSearchCursor = (cursor?: string): { offset: number } => {
if (cursor === undefined) {
return { offset: 0 };
}
try {
return SearchCursorPayloadSchema.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')));
} catch {
throw new BadRequestException('Invalid cursor');
}
};

View file

@ -0,0 +1,116 @@
import { UnauthorizedException } from '@nestjs/common';
import { isAlbumConfined, isFullyAlbumConfined, SearchFilter } from 'src/dtos/search.dto';
import { AssetVisibility } from 'src/enum';
import { applyLockedVisibilityPolicy, collectFilterIds } from 'src/utils/search-filter';
import { AuthFactory } from 'test/factories/auth.factory';
import { describe, expect, it } from 'vitest';
const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build();
const unelevatedAuth = () => AuthFactory.from().session().build();
describe(applyLockedVisibilityPolicy.name, () => {
it('should let an elevated session query locked assets', () => {
const filter = { visibility: { eq: AssetVisibility.Locked } };
expect(applyLockedVisibilityPolicy(elevatedAuth(), filter)).toBe(filter);
});
it('should reject an unelevated session when any operator permits locked', () => {
for (const visibility of [
{ eq: AssetVisibility.Locked },
{ ne: AssetVisibility.Timeline },
{ in: [AssetVisibility.Locked, AssetVisibility.Timeline] },
{ notIn: [AssetVisibility.Timeline] },
]) {
expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), { visibility })).toThrow(UnauthorizedException);
}
});
it('should keep a filter whose top-level visibility excludes locked', () => {
for (const visibility of [
{ eq: AssetVisibility.Timeline },
{ ne: AssetVisibility.Locked },
{ in: [AssetVisibility.Timeline, AssetVisibility.Archive] },
{ notIn: [AssetVisibility.Locked] },
]) {
const filter = { visibility };
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter);
}
});
it('should let a safe top-level visibility decide over could-match branches', () => {
const filter = { visibility: { ne: AssetVisibility.Locked }, or: [{ visibility: { eq: AssetVisibility.Locked } }] };
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter);
});
it('should reject a branch that permits locked when the top level has no visibility', () => {
const filter = {
or: [{ city: { eq: 'Oslo' } }, { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } }],
};
expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toThrow(UnauthorizedException);
});
it('should otherwise inject visibility != locked without mutating the input', () => {
const filter = { city: { eq: 'Oslo' }, visibility: undefined };
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toEqual({
city: { eq: 'Oslo' },
visibility: { ne: AssetVisibility.Locked },
});
expect(filter.visibility).toBeUndefined();
const branched = { or: [{ isFavorite: { eq: true } }, { visibility: { eq: AssetVisibility.Timeline } }] };
expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: AssetVisibility.Locked });
});
});
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(isAlbumConfined.name, () => {
it('should require a positive albumIds constraint', () => {
const albumId = '00000000-0000-4000-8000-00000000000a';
expect(isAlbumConfined({ albumIds: { any: [albumId] } })).toBe(true);
expect(isAlbumConfined({ albumIds: { all: [albumId] } })).toBe(true);
expect(isAlbumConfined({ albumIds: { none: [albumId] } })).toBe(false);
expect(isAlbumConfined({ city: { eq: 'Oslo' } })).toBe(false);
expect(isAlbumConfined({})).toBe(false);
});
});
describe(isFullyAlbumConfined.name, () => {
const albumId = '00000000-0000-4000-8000-00000000000a';
it('should be confined by the top level or by every branch', () => {
expect(isFullyAlbumConfined({ albumIds: { any: [albumId] } })).toBe(true);
expect(isFullyAlbumConfined({ albumIds: { any: [albumId] }, or: [{ city: { eq: 'Oslo' } }] })).toBe(true);
expect(isFullyAlbumConfined({ or: [{ albumIds: { any: [albumId] } }, { albumIds: { all: [albumId] } }] })).toBe(
true,
);
});
it('should not be confined when any result can escape the albums', () => {
expect(isFullyAlbumConfined({})).toBe(false);
expect(isFullyAlbumConfined({ albumIds: { none: [albumId] } })).toBe(false);
expect(isFullyAlbumConfined({ or: [{ albumIds: { any: [albumId] } }, { city: { eq: 'Oslo' } }] })).toBe(false);
expect(isFullyAlbumConfined({ or: [] })).toBe(false);
});
});

View file

@ -0,0 +1,73 @@
import { AuthDto } from 'src/dtos/auth.dto';
import { SearchFilter, SearchFilterBranch } from 'src/dtos/search.dto';
import { AssetVisibility } from 'src/enum';
import { requireElevatedPermission } from 'src/utils/access';
type EnumField = 'type' | 'visibility';
type EnumOperator = keyof NonNullable<SearchFilterBranch[EnumField]>;
type EnumOperandMap<T> = { eq: T; ne: T; in: T[]; notIn: T[] };
type EnumCondition<T> = { [K in EnumOperator]?: EnumOperandMap<T>[K] };
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 = <T>(condition: EnumCondition<T>, value: T): boolean => {
const { eq, ne, in: anyOf, notIn, ...unhandled } = condition;
// fails to compile when EnumFilter gains an operator this check does not consider
void (unhandled satisfies Record<string, never>);
return (
(eq === undefined || eq === value) &&
(ne === undefined || ne !== value) &&
(anyOf === undefined || anyOf.includes(value)) &&
(notIn === undefined || !notIn.includes(value))
);
};
/**
* The conditions that decide which `field` values the filter can return. A top-level condition decides alone, otherwise each branch itself.
*/
const decidingConditions = <F extends EnumField>(filter: SearchFilter, field: F) => {
if (filter[field] !== undefined) {
return [filter[field]];
}
return filterBranches(filter)
.map((branch) => branch[field])
.filter((condition) => condition !== undefined);
};
/**
* Keeps locked assets out of search results unless the session is elevated: a filter that asks for
* them is rejected with 401, and any other filter gets `visibility != locked` ANDed in.
*/
export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter): SearchFilter => {
if (auth.session?.hasElevatedPermission) {
return filter;
}
if (decidingConditions(filter, 'visibility').some((condition) => canMatch(condition, AssetVisibility.Locked))) {
requireElevatedPermission(auth);
}
if (filter.visibility !== undefined) {
return filter;
}
return { ...filter, visibility: { ne: AssetVisibility.Locked } };
};
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];
};

View file

@ -1,6 +1,6 @@
import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { AlbumUserRole, AssetOrder, AssetVisibility, SearchOrderField } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@ -16,6 +16,8 @@ import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const unitVector = (index: number) => JSON.stringify(Array.from({ length: 512 }, (_, i) => (i === index ? 1 : 0)));
const setup = (db?: Kysely<DB>) => {
return newMediumService(SearchService, {
database: db || defaultDatabase,
@ -56,7 +58,7 @@ describe(SearchService.name, () => {
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.searchLargeAssets(auth, {})).resolves.toEqual([
await expect(sut.searchLargeAssets(auth, { size: 250 })).resolves.toEqual([
expect.objectContaining({ id: assets[2].id }),
expect.objectContaining({ id: assets[0].id }),
expect.objectContaining({ id: assets[1].id }),
@ -120,7 +122,7 @@ describe(SearchService.name, () => {
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
const response = await sut.searchMetadata(auth, { size: 250, withStacked: false });
expect(response.assets.items.length).toBe(1);
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
@ -135,7 +137,7 @@ describe(SearchService.name, () => {
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { withStacked: false });
const response = await sut.searchMetadata(auth, { size: 250, withStacked: false });
expect(response.assets.items.length).toBe(0);
});
@ -148,7 +150,7 @@ describe(SearchService.name, () => {
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const response = await sut.searchMetadata(auth, { withStacked: false });
const response = await sut.searchMetadata(auth, { size: 250, withStacked: false });
expect(response.assets.items.length).toBe(1);
});
@ -168,7 +170,7 @@ describe(SearchService.name, () => {
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { albumIds: [album.id] });
const response = await sut.searchMetadata(auth, { size: 250, albumIds: [album.id] });
expect(response.assets.items.length).toBe(1);
});
@ -186,7 +188,7 @@ describe(SearchService.name, () => {
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.searchMetadata(auth, { albumIds: [album.id] })).rejects.toThrow(
await expect(sut.searchMetadata(auth, { size: 250, albumIds: [album.id] })).rejects.toThrow(
'Not found or no album.read access',
);
});
@ -222,9 +224,229 @@ describe(SearchService.name, () => {
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchRandom(auth, {});
const response = await sut.searchRandom(auth, { size: 250 });
expect(response.length).toBe(0);
});
});
describe('new search shape', () => {
it('should filter by an exif field and return a cursor-less single page', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, city: 'Oslo' });
const { asset: other } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: other.id, city: 'Bergen' });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { size: 250, filter: { city: { eq: 'Oslo' } } });
expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
expect(response.assets.nextPage).toBeNull();
expect(response.assets.nextCursor).toBeNull();
});
it('should combine OR branches', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: oslo } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: oslo.id, city: 'Oslo' });
const { asset: favorite } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
await ctx.newAsset({ ownerId: user.id });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, {
size: 250,
filter: { or: [{ city: { eq: 'Oslo' } }, { isFavorite: { eq: true } }] },
});
expect(response.assets.items.map(({ id }) => id).toSorted()).toEqual([oslo.id, favorite.id].toSorted());
});
it('should scope a top-level album constraint to the album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, { size: 250, filter: { albumIds: { any: [album.id] } } });
expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
});
it('should scope an album-constrained branch to the album', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, {
size: 250,
filter: { or: [{ albumIds: { any: [album.id] } }] },
});
expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
});
it('should keep the ownership scope for branches without an album constraint', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { asset: ownOslo } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: ownOslo.id, city: 'Oslo' });
const { asset: foreignOslo } = await ctx.newAsset({ ownerId: otherUser.id });
await ctx.newExif({ assetId: foreignOslo.id, city: 'Oslo' });
const { asset: albumAsset } = await ctx.newAsset({ ownerId: otherUser.id });
const { album } = await ctx.newAlbum({ ownerId: user.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: albumAsset.id });
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, {
size: 250,
filter: { or: [{ albumIds: { any: [album.id] } }, { city: { eq: 'Oslo' } }] },
});
// the album branch searches the album, the city branch stays confined to the caller's own assets
expect(response.assets.items.map(({ id }) => id).toSorted()).toEqual([ownOslo.id, albumAsset.id].toSorted());
});
it('should reject an inaccessible album anywhere in the filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: otherUser } = await ctx.newUser();
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
const auth = factory.auth({ user: { id: user.id } });
await expect(
sut.searchMetadata(auth, { size: 250, filter: { or: [{ albumIds: { none: [album.id] } }] } }),
).rejects.toThrow('Not found or no album.read access');
});
it('should return locked assets only to an elevated session that asks for them', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset: timeline } = await ctx.newAsset({ ownerId: user.id });
const { asset: locked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const unelevated = await sut.searchMetadata(factory.auth({ user: { id: user.id } }), { size: 250, filter: {} });
expect(unelevated.assets.items).toEqual([expect.objectContaining({ id: timeline.id })]);
const elevatedAuth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const elevated = await sut.searchMetadata(elevatedAuth, {
size: 250,
filter: { visibility: { eq: AssetVisibility.Locked } },
});
expect(elevated.assets.items).toEqual([expect.objectContaining({ id: locked.id })]);
});
it('should exclude partner assets from a locked-only search', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: partner } = await ctx.newUser();
await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id });
const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const response = await sut.searchMetadata(auth, {
size: 250,
filter: { visibility: { eq: AssetVisibility.Locked } },
});
expect(response.assets.items).toEqual([expect.objectContaining({ id: ownLocked.id })]);
});
it('should never return partner locked assets, even for locked-matching mixed filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { user: partner } = await ctx.newUser();
await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id });
const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
const { asset: partnerTimeline } = await ctx.newAsset({ ownerId: partner.id });
await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked });
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
const response = await sut.searchMetadata(auth, {
size: 250,
filter: { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } },
});
const ids = response.assets.items.map(({ id }) => id);
expect(ids.toSorted()).toEqual([ownLocked.id, partnerTimeline.id].toSorted());
});
it('should paginate with an opaque cursor', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
for (let i = 0; i < 3; i++) {
await ctx.newAsset({ ownerId: user.id });
}
const auth = factory.auth({ user: { id: user.id } });
const firstPage = await sut.searchMetadata(auth, { filter: {}, size: 2 });
expect(firstPage.assets.items.length).toBe(2);
expect(firstPage.assets.nextPage).toBeNull();
expect(firstPage.assets.nextCursor).toEqual(expect.any(String));
const secondPage = await sut.searchMetadata(auth, { cursor: firstPage.assets.nextCursor!, size: 2 });
expect(secondPage.assets.items.length).toBe(1);
expect(secondPage.assets.nextCursor).toBeNull();
const ids = [...firstPage.assets.items, ...secondPage.assets.items].map(({ id }) => id);
expect(new Set(ids).size).toBe(3);
});
it('should order by fileSizeInBytes', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const sizes = [12_334, 599, 123_456];
const assetIds: string[] = [];
for (const fileSizeInByte of sizes) {
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, fileSizeInByte });
assetIds.push(asset.id);
}
const auth = factory.auth({ user: { id: user.id } });
const response = await sut.searchMetadata(auth, {
size: 250,
orderBy: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Asc },
});
expect(response.assets.items.map(({ id }) => id)).toEqual([assetIds[1], assetIds[0], assetIds[2]]);
});
it('should order smart search results by embedding distance with cursor offsets', async () => {
const { ctx } = setup();
const { user } = await ctx.newUser();
const searchRepository = ctx.get(SearchRepository);
const assetIds: string[] = [];
for (let i = 0; i < 3; i++) {
const { asset } = await ctx.newAsset({ ownerId: user.id });
await searchRepository.upsert(asset.id, unitVector(i));
assetIds.push(asset.id);
}
const options = { filter: {}, embedding: unitVector(0) };
const scope = { userIds: [user.id], lockedOwnerId: user.id };
const firstPage = await searchRepository.searchSmartV3({ take: 2 }, options, scope);
expect(firstPage.items.length).toBe(2);
expect(firstPage.items[0].id).toBe(assetIds[0]);
expect(firstPage.hasNextPage).toBe(true);
const secondPage = await searchRepository.searchSmartV3({ take: 2, skip: 2 }, options, scope);
expect(secondPage.items.length).toBe(1);
expect(secondPage.hasNextPage).toBe(false);
});
});
});