Skip to content

feat(server): new search API#30179

Draft
timonrieger wants to merge 12 commits into
mainfrom
refactor/search-v3/pr-2a
Draft

feat(server): new search API#30179
timonrieger wants to merge 12 commits into
mainfrom
refactor/search-v3/pr-2a

Conversation

@timonrieger

@timonrieger timonrieger commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

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": {} }
asset returned?
your timeline asset yes
your archived / hidden asset yes — search is not the timeline; only locked is excluded
partner's timeline asset yes
your trashed asset yes — no implicit trash filter (unlike the legacy flat API)
your locked asset no — injected visibility

2. AND at the top, OR in the branches

{ "filter": { "isFavorite": { "eq": true }, "or": [{ "city": { "eq": "Oslo" } }, { "city": { "eq": "Bergen" } }] } }
asset returned?
favorite in Oslo yes
favorite in Bergen yes
favorite in Paris no — no branch matches
non-favorite in Oslo no — top level ANDs into every branch

3. Trash state (trashedAt replaces withDeleted)

filter meaning
omitted trashed and non-trashed
"trashedAt": { "eq": null } non-trashed only (the legacy default)
"trashedAt": { "ne": null } trash only
"trashedAt": { "gte": "2026-01-01T00:00:00Z" } trashed since that date

4. People and tags — any/all/none, and the access asymmetry

{ "filter": { "personIds": { "all": ["<p1>", "<p2>"] } } }
condition meaning
"all": [p1, p2] both people in the photo
"any": [p1, p2] at least one of them
"none": [p1, p2] neither of them
filter field foreign id in the filter
albumIds request fails: Not found or no album.read access — album ids are access-checked because they can widen the universe
personIds / tagIds silently empty results — never widens the universe, so no access check

Scope and albums

5. Album branch ORed with an exif branch

{ "filter": { "or": [{ "albumIds": { "any": ["<album-a>"] } }, { "city": { "eq": "Oslo" } }] } }

Album a is readable by the caller. The album constraint is branch-level, so the ownership scope
is kept.

asset returned?
your/partner asset in album a yes
your/partner Oslo asset (not in the album) yes
another user's asset in album a (album you can read) no — ownership scope kept; widening here would leak every user's Oslo assets through the other branch
another user's Oslo asset no
your locked Oslo asset no — injected visibility

6. Moving the album constraint to the top level

{ "filter": { "albumIds": { "any": ["<album-a>"] }, "or": [{ "city": { "eq": "Oslo" } }, { "isFavorite": { "eq": true } }] } }

Top-level any ANDs 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
or is predicate-equal but does not widen.

asset returned?
another user's Oslo asset in album a yes — scope widened to the album
another user's favorited asset in album a yes
your Oslo asset not in album a no — top-level album condition ANDs everywhere
another user's asset in album a, neither Oslo nor favorite no — branch predicate
any locked asset in album a no — injected visibility

7. Shared links

request (via shared link) outcome
{ "filter": { "city": { "eq": "Oslo" } } } 400 Shared link access is only allowed in combination with an albumIds filter
{ "filter": { "or": [{ "albumIds": { "any": ["<a>"] } }] } } 400 — branch-level doesn't confine every result
{ "filter": { "albumIds": { "any": ["<a>"] } } }, link covers a 200 — results confined to album a
{ "filter": { "albumIds": { "any": ["<a>"] } } }, link does not cover a Not found or no album.read access

Visibility policy

8. The 401 surprise: exclusion can still permit locked

The policy judges conditions by what they permit, not what they mention.

"visibility" condition outcome reasoning
{ "eq": "locked" } 401 directly targets locked
{ "ne": "timeline" } 401 permits archive, hidden — and locked
{ "notIn": ["timeline"] } 401 same: locked not excluded
{ "in": ["locked", "timeline"] } 401 includes locked
{ "ne": "locked" } 200 provably cannot match locked
{ "notIn": ["locked", "hidden"] } 200 locked excluded; returns timeline + archive
{ "in": ["timeline", "archive"] } 200 locked not included

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).

request outcome
{ "filter": { "visibility": { "eq": "timeline" }, "or": [{ "visibility": { "eq": "locked" } }, { "isFavorite": { "eq": true } }] } } 200, favorite timeline assets — no 401 (the safe top level decides alone); the locked branch is unsatisfiable and contributes zero rows
{ "filter": { "visibility": { "in": ["locked"], "notIn": ["locked"] } } } 200, empty — the condition provably matches nothing, including locked, so no 401 either

Pagination

10. Cursor mechanics and gotchas

request response
{ "filter": { "type": { "eq": "IMAGE" } }, "size": 2 } items: [a, b], nextPage: null, nextCursor: "eyJvIjoyfQ"
same filter + "cursor": "eyJvIjoyfQ" items: [c], nextCursor: null
a different filter + "cursor": "eyJvIjoyfQ" 200 — but the position belongs to another result list; rows get skipped/duplicated, no error
"cursor": "garbage" 400 Invalid cursor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant