feat(server): new search API#30179
Draft
timonrieger wants to merge 12 commits into
Draft
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
WIP
The new search API implemented alongside the existing one. Drop legacy search API methods and DTOs in v4. Endpoints and fields are deprecated accordingly, so that clients can migrate.
Implemented as PR 2a as documented in https://outline.immich.cloud/doc/new-search-api-rPnirxTk6G.
Most new code is openapi generated and test code. The actual logic is manageable. Below are some examples for both reviewers and users to how to use the new API.
Examples
Every search runs a predicate (the filter tree) inside a universe (the ownership scope). Tthe filter decides which rows match, the universe decides which rows were searchable at all.
Basics
1. The empty filter
{ "filter": {} }2. AND at the top, OR in the branches
{ "filter": { "isFavorite": { "eq": true }, "or": [{ "city": { "eq": "Oslo" } }, { "city": { "eq": "Bergen" } }] } }3. Trash state (
trashedAtreplaceswithDeleted)"trashedAt": { "eq": null }"trashedAt": { "ne": null }"trashedAt": { "gte": "2026-01-01T00:00:00Z" }4. People and tags —
any/all/none, and the access asymmetry{ "filter": { "personIds": { "all": ["<p1>", "<p2>"] } } }"all": [p1, p2]"any": [p1, p2]"none": [p1, p2]albumIdsNot found or no album.read access— album ids are access-checked because they can widen the universepersonIds/tagIdsScope and albums
5. Album branch ORed with an exif branch
{ "filter": { "or": [{ "albumIds": { "any": ["<album-a>"] } }, { "city": { "eq": "Oslo" } }] } }Album
ais readable by the caller. The album constraint is branch-level, so the ownership scopeis kept.
aa(album you can read)6. Moving the album constraint to the top level
{ "filter": { "albumIds": { "any": ["<album-a>"] }, "or": [{ "city": { "eq": "Oslo" } }, { "isFavorite": { "eq": true } }] } }Top-level
anyANDs into every branch — every result is provably inside the access-checked album,so the ownership scope is dropped. This is the canonical way to ask for widening; a single-branch
oris predicate-equal but does not widen.aaaa, neither Oslo nor favoritea7. Shared links
{ "filter": { "city": { "eq": "Oslo" } } }Shared link access is only allowed in combination with an albumIds filter{ "filter": { "or": [{ "albumIds": { "any": ["<a>"] } }] } }{ "filter": { "albumIds": { "any": ["<a>"] } } }, link coversaa{ "filter": { "albumIds": { "any": ["<a>"] } } }, link does not coveraNot found or no album.read accessVisibility policy
8. The 401 surprise: exclusion can still permit locked
The policy judges conditions by what they permit, not what they mention.
"visibility"condition{ "eq": "locked" }{ "ne": "timeline" }{ "notIn": ["timeline"] }{ "in": ["locked", "timeline"] }{ "ne": "locked" }{ "notIn": ["locked", "hidden"] }{ "in": ["timeline", "archive"] }9. Contradictions are silent, not errors
There is no unsatisfiability checker: dead branches and impossible conditions return nothing
rather than erroring, and they fail safe (empty, never wider).
{ "filter": { "visibility": { "eq": "timeline" }, "or": [{ "visibility": { "eq": "locked" } }, { "isFavorite": { "eq": true } }] } }{ "filter": { "visibility": { "in": ["locked"], "notIn": ["locked"] } } }Pagination
10. Cursor mechanics and gotchas
{ "filter": { "type": { "eq": "IMAGE" } }, "size": 2 }items: [a, b],nextPage: null,nextCursor: "eyJvIjoyfQ""cursor": "eyJvIjoyfQ"items: [c],nextCursor: null"cursor": "eyJvIjoyfQ""cursor": "garbage"Invalid cursor