Skip to content

feat(filters): find photos that have no location (#868) - #994

Open
Deeds67 wants to merge 1578 commits into
mainfrom
feat/no-location-filter
Open

Deeds67 wants to merge 1578 commits into
mainfrom
feat/no-location-filter

Conversation

@Deeds67

@Deeds67 Deeds67 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Closes the request in #868: a way to find photos that carry no location.

The location filter can currently only narrow to a place — it lists countries, expands each into cities, and displays an incoming state it cannot browse. There is no way to express the absence of a place, so the photos that most need finding are the only ones the filter cannot reach.

What this adds

Two mutually exclusive entries in the location filter section:

Entry Matches
No location assets with no coordinates at all — never geotagged
Unnamed place assets that have coordinates the reverse geocoder could not name (ocean, remote area, or the geocode job has not run)

They are deliberately disjoint. Their union is the complement of a fully-located asset (neither matches ⟺ latitude IS NOT NULL AND city IS NOT NULL), which a medium test asserts directly.

Design notes

noGps is a correlated NOT EXISTS, not a join predicate. asset_exif rows are not guaranteed — an external library import inserts into asset only, with no exif row until the extraction job reaches it (hours during a large scan, permanently if extraction failed). An inner join would drop exactly the assets that most obviously have no GPS. This mirrors the existing tagIds === null ("assets with no tags") predicate.

locationPresence joins the location group rather than adding a dimension. city, state, country and locationPresence remain one filter with one chip: selecting any one replaces the whole group, the active-filter count is unchanged, and the DTOs reject the combination rather than silently resolving it. It also joins the suggestion self-exclusion lists, so selecting one entry cannot empty the country list or hide its sibling.

Both query builders. The predicates land in searchAssetBuilderLegacy and withTimeBucketAssetFilters, which a comment in database.ts requires to agree; a test asserts both return the same ids.

The map suppresses both entries. Map markers join on asset_exif.latitude IS NOT NULL, so "No location" could only ever produce an empty map. The map forces both gating flags off and drops the value at URL-decode time, so a link copied from /photos cannot leave an invisible filter applied there.

No new index. Neither asset_exif.latitude nor city has a btree index, and the predicates ride the primary key after the query's owner and visibility gates have narrowed the set. A partial index on a low-selectivity predicate is unlikely to pay for itself; revisit with EXPLAIN ANALYZE on a large library if it proves slow.

Scope

Server predicates and DTOs, two suggestion flags gating whether each entry is offered, regenerated TypeScript and Dart clients, strings in ten locales, the web filter panel (state, URL codec, rows, per-surface forwarding, map suppression), and all three mobile places surfaces.

Not included: the /search route's typed-search tokens, surfacing unlocated assets on the map, and the dormant V3 branch filter.

Testing

Medium tests cover both predicates, their disjointness, the partition identity, the no-exif-row case, cross-user isolation, trash and archive gating, and shared-space access. Unit tests cover the DTO exclusivity across every controller-facing schema, the web filter state, URL round-trip and removal, the panel rows, and the mobile model, chip, providers and three surfaces.

One gap: the Playwright spec (e2e/src/specs/web/no-location-filter.e2e-spec.ts) and the API-level test for GET /timeline/buckets?locationPresence=noGps have not been executed — no compatible stack was available locally. Every selector, helper and fixture was verified against source, but they need a real run before being trusted.

Known limitation

getTimeBucket's projection CTE joins asset_exif unconditionally, so an asset with no exif row is counted by getTimeBuckets but dropped from the rendered rows. "No location" is the filter designed to surface exactly those assets, so its bucket count can exceed the photos drawn during a library scan.

This is pre-existing and not introduced here — the join is unconditional, so the same disagreement reproduces for any query that lets a row-less asset through, including an unfiltered timeline. The fix is a left join, which alters every timeline query in the application and would start surfacing unprocessed assets in the main timeline; that is a product decision rather than a bug fix, and is deliberately out of scope. Documented in the design spec with a follow-up worth filing.

Follow-ups

  • i18n/ru.json — Без места / Место без названия share the root noun место. местоположение would separate them but conflicts with the location filter's sibling strings. Wants a native speaker.
  • _SearchMoreRow reads "Search 0 places →" in the zero-countries state; correcting it needs new strings in ten locales.
  • _PresenceEntry is defined once per mobile surface file; a shared type would remove the duplication.

@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server 🖥️web 📱mobile labels Aug 15, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Aug 15, 2026
@Deeds67
Deeds67 force-pushed the feat/no-location-filter branch from 0d3e2c7 to d5f33cd Compare August 23, 2026 17:42
@Deeds67
Deeds67 force-pushed the feat/no-location-filter branch from 6635ddd to eb12d11 Compare September 2, 2026 20:35
@Deeds67
Deeds67 force-pushed the feat/no-location-filter branch from eb12d11 to efc3d04 Compare September 11, 2026 19:19
Records the memories-view reconciliation cycle: the product-direction
gate on immich-28675, the two zero-conflict Critical bugs found in
review (upstream's Upcoming section provably always empty; pagination
and deep links broken on the fork-only served path), the standing
sidebarWeb/mobile-sourcing/e2e-generator divergences, and the
deliberate user-visible regressions to call out in release notes.
Remote CI on rebase/upstream-batch-161 is dispatched but not yet
resolved.
…ories view

Adopting upstream's memories page and viewer byte-identically (immich-28675)
silently reverted three fork behaviours. None of them produced a conflict, a
type error or a test failure.

- The memories index is member 25 of the fork's 25-file branded-spinner swapped
  set, so it went back to `@immich/ui`'s generic LoadingSpinner instead of the
  Gallery-branded one. The upstream-preflight guard caught this; re-applied the
  swap in the same shape as the geolocation page.

- The viewer stopped passing `enableGrouping` to GalleryViewer, so asset
  grouping (#625) defaulted off on the memory gallery strip. The prop is alive
  and still honoured on the component; re-added it and asserted it in the ported
  MemoryViewer spec, which now goes red if it is dropped again.

- The card rendered the title only, so the rule engine's subtitles stopped
  reaching the UI: a recent-trip memory lost its "12 photos over 3 days",
  getMemorySubtitle became dead code and recent_trip_subtitle was orphaned in
  ten locales. Rendered it as a conditional second line, guarded by a new
  memories-page spec.

Also formats MemoryViewer.spec.ts, which was failing //web:format.
…d page

getAllMemories stopped paging as soon as a page came back shorter than the page
size. That reads the wrong number: the server applies LIMIT/OFFSET in SQL
(memory.repository.ts searchAccessible) and only afterwards drops memories whose
type the viewer disabled and memories left with no viewable assets
(memory.service.ts search). A page can therefore arrive short -- or empty -- with
plenty of rows still behind it, so any user who turned a single memory type off
got a short first page and a list that silently ended there.

Key the stop on GET /memories/statistics instead. statisticsAccessible counts
through the same accessibleSearchBuilder predicates without the LIMIT, so it is
the one signal that reflects what the server actually paged over; web's
memory-manager stops the same way. A missing or failing count degrades to an
empty-page stop rather than back to the post-filter length, and never fails the
call into the offline local-Drift fallback over a count the list can do without.
The 50-page backstop is unchanged.

The new "keeps paging past a page the server filtered short" case returns 3
memories against the old condition and 53 against this one.
…egressions

Three corrections to this cycle's record, all found in the whole-branch review.

D5 claimed rule-aware titles and subtitles were both preserved for free. Only
titles were: upstream's page and viewer import memoryLaneTitle, and nothing on
either surface imports getMemorySubtitle -- its one caller was the deleted
memory-index-utils.ts. Reworded, with a dated note recording that the subtitle
was restored on upstream's card rather than deleted, and why. The design doc now
also records enableGrouping (#625) as a delta that must be re-applied on
upstream's viewer.

The report said the missing page offset made pagination "return page 1 forever,
capping the list at roughly 250". Wrong: applyPreferences replaces the filter
object wholesale and drops `size`, and the server applies LIMIT only when `size`
is present, so the served path was unpaginated rather than capped. The fix that
landed is still needed -- it is what lets a size-carrying caller page at all.

/explore sends neither `for` nor `isUpcoming`, which is exactly the combination
this cycle changed, so it now surfaces not-yet-shown memories in one flat strip
with no Upcoming affordance. Documented in the user-visible behaviour-change
list; deliberately not changed, since both workarounds re-introduce the
caller/DTO ambiguity that change removed.
Reorder Tailwind classes to satisfy better-tailwindcss/enforce-consistent-class-order,
and scope the item-card p query with :scope to satisfy unicorn/prefer-scoped-selector.
All 10 workflows green. Notes the two real defects CI caught that no local
gate did (the branded-spinner swapped-set regression from adopting upstream's
page byte-identically, and two lint violations in files eslint cannot check
locally), and separates them from a GitHub infrastructure incident that
reddened three intermediate runs while the status page read all-clear.
… block

The batch-164 lockfile conflict (immich-30990, typescript-projects) was resolved by
regenerating with injectWorkspacePackages temporarily disabled, so pnpm omitted the
setting from the lockfile's settings block. Restore it so the block matches both
upstream and the fork's previous lockfile byte for byte; workspace deps stay on
`version: link:` (11 of them, zero `version: file:`).
…ract changes

Two zero-conflict semantic breaks from this batch:

- immich-30908 made OAuthRepository.getProfilePicture return the ArrayBuffer
  directly instead of { data, contentType }. Upstream converted its own two
  mocks; the fork's six S3 profile-picture mocks in auth.service.spec.ts still
  built the old object and failed tsc. The two remaining contentType literals
  are the fork's mockS3Backend.put assertions, which are unrelated and stay.

- immich-31016 replaced the RapidOCR wrapper with direct OCR processing,
  deleting models.ocr.recognition.RapidTextRecognizer. The fork-only test
  test_passes_model_root_dir_to_rapidocr patched that symbol, so it errored at
  collection. recognition.py is byte-identical to upstream here, and the
  property the test guarded — OCR models resolving from the local cache dir
  rather than the network — is now structural in _load(), which builds an
  OrtSession from self.model_path and reads self.model_dir / charset.txt.
  The test is obsolete, so remove it rather than re-point it at a private.
…n nothing has scanned

immich-31006 wraps the SDK's fetch in a `jsonOnly` guard that throws
MalformedResponseError when a JSON-accepting request gets a non-204 response whose
content-type is not JSON. `getLatestScan` returned `FaceRepairScanStatusDto | null`,
and Nest emits a bare `null` as a 200 with an empty body and no content-type — so on a
fresh instance every caller of getLatestScan() started throwing. The face-cleanup admin
console caught it and rendered its load-error state instead of the first-run empty state,
failing the fork's face-cleanup web e2e spec through all five retries.

Upstream has no controller returning a nullable DTO, so nothing on their side exercises
this and their own suites stayed green — the fork is the only consumer of the pattern.

204 is the honest representation of "no scan yet" and is exactly what the guard passes
through untouched. Both statuses are now declared with @apiresponse, following
asset-media.controller.ts's dual-status precedent, so the generated client models it as
`{status:200; data: FaceRepairScanStatusDto} | {status:204}` rather than the previous
untyped `data: object`. All four web call sites already treat a falsy result as "no
scan", and no Dart/mobile client consumes this admin-only endpoint.
…ns-gallery

immich-30997/immich-30998 add a `server/src/schema/migrations/ORDER` manifest plus a
`Migration Order` workflow that verifies it on every PR. Two fork-side changes were needed.

The workflow's first step mints a token via immich-app's `create-workflow-token` action and
the `PUSH_O_MATIC_APP_*` secrets, which this repo does not have — every PR would have gone red
on it, and `make ci-invariants-check` forbids that pattern outright. Dropped the step; checkout
uses the default credentials and `use-mise` takes `${{ github.token }}`, matching every other
fork workflow.

Upstream's manifest only covers `migrations/`, so the fork's 61 `migrations-gallery/`
migrations were unguarded. Added an ORDER manifest for that folder too, with a
`migrations-gallery` mise task, `migrations:{sync,verify}-order:gallery` scripts, and a
checklist entry.

The gallery folder is checked for consistency only, NOT append-only. Gallery migrations use
hand-picked round timestamps and the server runs `allowUnorderedMigrations: true`
unconditionally (upstream allows it in dev only), so a branch landing with a lower timestamp
than one merged meanwhile is safe here. Consistency alone still makes two branches that both
add a gallery migration conflict in git on ORDER's tail, which is the reason upstream added it.

Both checks were proven to fail as well as pass: a doctored append-only baseline and a
gallery ORDER with a line removed each exit 1.
… baseline

Two follow-ups to the adapted immich-30998 workflow.

The rolling rebase branch sits off `main` for long stretches, and the workflow only triggers on
`pull_request` / `push: main`, so it would never run there. Added `workflow_dispatch` — it becomes
usable once the file reaches the default branch.

`--append-only-from` reads the base commit's ORDER file unconditionally, which throws whenever the
base predates the manifest. That is not hypothetical: at the next `main` cutover
`github.event.before` is the pre-rebase main, which has no ORDER file, so the push run would have
failed. The step now falls back to the plain consistency check and emits a notice, and resumes
enforcing append-only as soon as the base carries an ORDER file.

Simulated all three states: baseline present + valid exits 0, baseline absent exits 0 via the
consistency path, baseline present + violated still exits 1.
A three-wave multi-agent review of this branch turned up 58 defects. This
records the decisions taken and the traps worth keeping, not the full
findings list — a standing wishlist rots.

The durable half is Part 1: five CI gates that look green and prove nothing,
and three detectors that earned their keep (git ls-tree set algebra for fork
deletions upstream later resurrects; zero-producer test-id greps; and treating
a gate that does not cover what it appears to as a finding in itself).

Part 2 carries one entry per decided fix with its mechanism, sites and the
assertion that would prove it, so the work can be done from this file.
Each of these looks green and proves nothing. Together they are why several
defects survived a 10/10 CI run.

- Schema drift: `pnpm --filter migrations:generate` selects PACKAGES, not
  scripts, so it matched nothing, exited non-zero, and continue-on-error ate
  it. This is the only detector for a table declaration drifting from what the
  migrations produce — during the option-M landing it was the sole thing that
  caught the face-review tables declaring personId with FKs to a dropped
  column while tsc sat at zero. Name the package, and let failure go red.

- Migration ORDER: `github.event.before` exists only on push, so on
  workflow_dispatch — the only mode a branch off main can use — the base
  checkout fell back to the triggering ref and --append-only-from compared
  ORDER against itself. Skip the checkout instead and let the existing
  missing-baseline path degrade to the consistency check, with its notice.

- upstream-preflight: the path filter watched three of the nine paths its
  specs read, so a PR reverting the branded-spinner swap or dropping the
  migration compatibility alias ran with the job skipped — and a skipped job
  is indistinguishable from a passing one. AGENTS.md is listed alongside
  CLAUDE.md because the latter is a symlink.

- ci-invariants-check: run by no workflow at all; it fired only when someone
  typed it. Wired in. It also read zero files for a declared path that no
  longer resolves and reported "passed", so an upstream relocation turned the
  gate green — now that invariant fails and names the path, while the others
  still report.

- dart-nullable-array-items: invoked via a package script carrying
  --passWithNoTests, so renaming the spec would exit 0. Call vitest directly.

Also completes the branded-spinner guard, which listed 25 of 27 call sites.
The list is hand-maintained and drifts as the swap set grows, so the fix is a
third test deriving the truth from the tree rather than two more paths.

Both new failure modes were proved red before being trusted.
Both are Shape I in its hardest form: the fork's rule is a DELETION, so
upstream re-creating the path produces a delete/modify conflict that resolves
toward upstream leaving no textual trace and no gate to notice.

`packages/scripts` was dropped in bc06e84 as upstream release-version
tooling the fork does not use. A Renovate commit touched its package.json this
cycle and the replay brought the file back alone — an orphan workspace member
declaring build/check/lint/test scripts over no source, plus six phantom
dependencies in the lockfile. Nothing in CI built it, and --frozen-lockfile
passed because the lockfile had been regenerated with the importer. The
forward risk was the real one: with the path present, the next upstream batch
adding files under it would apply cleanly rather than conflict.

`e2e/docker-compose.yml` lost its cache_from removal (#171) the same way. Both
tags 404, so today it costs two failed registry probes per build, but if one
ever resolves the fork's e2e image imports upstream-built layers.

Adds fork-deletions.spec.ts so the next occurrence fails a test instead of
landing silently, and extends the upstream-preflight path filter to packages/**
so that test actually runs on a PR that resurrects one.

Lockfile regenerated with injectWorkspacePackages left at true: 11 `link:`
entries byte-identical to before, 0 `file:packages/`, frozen install clean.
The paragraph named 3.44.9 while both real pins said 3.47.1 — upstream moved
the pin at the new base, and this line was independently edited to a different
wrong value in the same cycle. It also quoted a mise.toml syntax that no longer
exists.

It already warned "read the pin rather than trusting this line; it has gone
stale before" and then restated the number anyway, which is the trap. Name
where the pin lives instead of what it currently says.
immich-28675 moved the memory viewer from /memory?id= to /memories/:id. The
fork adopted the move, but the deep-link table in open-in-app.ts is fork-only,
so upstream never touched it and the rename produced no conflict: every real
memory URL stopped matching, pathToDeepLink returned null, and the banner
silently never rendered on mobile web.

Its spec passed 39/39 throughout because it pinned the retired paths, and the
e2e spec only ever navigates to /photos/*. Adds the live routes with coverage
that goes red without them, and keeps the old ones — the 307 shim still serves
shared and bookmarked links.

Also records why mobile/analysis_options.yaml carries no convergence marker:
one would break the byte-identity that taking upstream's file buys.

Note the mobile half is still incomplete: deep_link.service.dart handles only
path == "/memory" and discards ?id=, so a memory-id deep link needs a mobile
change before the emitted id does anything.
The location section renders 'No locations found' when there are no countries
-- which is exactly a library with nothing geotagged, the case this feature
serves. Guarding only on a selected presence hides the rows on first open.
…ionsResponseDto construction sites

The Task 4 Dart client regen marked both fields required, which had left
dart analyze red across every call site that builds this DTO by hand
(the lib fallback-response construction plus test fixtures/mocks in
files outside Tasks 10/11's scope). Mechanical fix: add both arguments
everywhere. Test fixtures use false (empty fixtures); the lib fallback
site also uses false since it only fires when the live API response is
null — the live path still returns the server's real values unchanged.
Placed at e2e/src/specs/web/ (the real-backend project) rather than the
brief's e2e/src/ui/specs/timeline/ path, which belongs to the mocked-network
"ui" project and has no real database to seed GPS EXIF against.
city/state/country/locationPresence are one location group, but four
request-building sites only ever forwarded the first three:
filter-panel.svelte's unified re-fetch effect, the photos and space
detail pages' suggestion loaders, and album-filter-config.ts. With "No
location" selected, the People/Tags/Camera-make suggestion lists kept
describing the unfiltered set instead of narrowing, so mobile and web
diverged. map-filter-config.ts is untouched — it deliberately
suppresses this filter.

Adds a panel-level test proving the unified suggestionsProvider
receives locationPresence when a no-location entry is selected.
No test anywhere exercised the DTO -> service -> repository wire path
for locationPresence; the medium tests call the repository directly.
Adds a sibling to the existing city+make case: every fixture asset in
this describe block gets coordinates in the outer beforeAll, so a
correctly wired noGps filter returns zero buckets against a non-zero
unfiltered baseline -- a silently dropped param would instead return
the same non-zero count as unfiltered.

Not executed: the only live e2e stack was built from a different
branch and doesn't contain these server changes.
getFilterSuggestions grew a 7th parallel query (location presence)
during this branch, and the Countries extraction now also excludes
state alongside country/city. Updates the "What updates" table, the
server flow's query count and list, and the shared-helper count to
match.
The row-less-asset paragraph named a freshly uploaded, not-yet-
extracted asset as the case that inflates a noGps bucket count. That
case barely exists: asset-media.service.ts:389 upserts an asset_exif
row (with fileSizeInByte) during upload, before extraction runs.

The real case is external library imports: library.service.ts:275 ->
AssetRepository.createAll inserts into asset only, with no asset_exif
row until the extraction job processes it -- and during a large
Connected Libraries scan that gap is hours, not seconds. The same gap
is permanent for any asset whose extraction job failed. Drops the
"transient in practice, seconds" framing; the deferred-fix decision is
unchanged.
The Known-limitation paragraph was corrected to name external library
imports as the source of row-less assets, but the data-model section
still claimed uploads leave no exif row. Uploads write one at
asset-media.service.ts:389, before extraction.
getSmartSearchFacets and getFilterSuggestions both gained
hasNoGpsAssets/hasNoPlaceNameAssets, and these two tests pin the whole
response object with toEqual, so any new field breaks them regardless of
correctness.
…ng empty

The noGps bucket assertion assumed an empty result from this describe's
all-geotagged fixture, but other describes in the same file create
admin-owned assets with no coordinates against the same database, so
noGps legitimately matches those. Comparing filtered against unfiltered
keeps the test falsifiable without depending on global DB state.
The local helper tripped unicorn/consistent-function-scoping; the
neighbouring assertions in this file already reduce inline.
Two space filter-panel tests asserted that a space whose assets carry no
EXIF shows the 'no locations found' message. Those assets are exactly what
the new filter finds, so the section now offers the 'No location' row
instead — reporting the section empty beside a working filter for it would
contradict itself. Camera keeps its empty message.
Restores the regeneration that the rebase onto #910 collapsed: the generated
dump now carries the two location-presence facet queries alongside the
favourites/album-membership ones main added. The Dart models are re-emitted
from the same spec so the hand-merged blank line matches the generator.
… entries exist

#910 landed a section-availability gate that hides a filter section whose facet
cannot filter anything, and it judges the location section on `countries` alone.
That predates #868: a library where nothing has coordinates now has something to
offer there — the "No location" entry itself — so the gate was hiding the very
feature that scope exists for, on both web and mobile.

- web `isSectionEmpty` and mobile `_facetEmpty` now also consult
  hasNoGpsAssets / hasNoPlaceNameAssets before calling the section empty.
- mobile `hasActiveFilterFor(places)` counts an active locationPresence, so
  selecting an entry can never strand it behind a hidden section.
- mobile `_isEmptyForFacets` counts locationPresence too — the facets request
  forwards it, so a filter carrying only that field must still take a distinct
  baseline key rather than reusing the current one.
- the two album pages #993 added seed the new facet fields in their local
  emptyFilterSuggestions().
#910's "hides the sections that cannot filter anything" seeds one asset with no
EXIF and asserts all eight gated sections vanish. Since #868 that is no longer
true of location: an asset with no coordinates is exactly what the "No location"
entry filters for, so the section can filter something and stays.

Drops location from the hidden loop and asserts the positive instead — the
section renders and offers the noGps row rather than its empty placeholder. The
sibling spaces-filter-panel assertion already passed this run, so this is the
last place the old expectation survived.
#1021 moved durable design docs to top-level specs/ and stopped committing
execution plans at all — they are session checklists that expire when the work
lands, and under docs/ they were prettier-gated and tripped Docs Build. The
rebase carried the design doc across to specs/ on its own; the plan is deleted
rather than resurrecting docs/superpowers/plans/.

Claude-Session: https://claude.ai/code/session_01XKxtXPAwtQgFHgmRux2rcF
location-country-nesting.spec.ts landed on main while this branch was open, and
its suggestions() helper builds a FilterSuggestionsResponse literal — which now
requires hasNoGpsAssets / hasNoPlaceNameAssets. Both default to false, so the
absence-of-location rows stay out of the list and the nesting assertions still
see nothing between the search box and the country rows.

Claude-Session: https://claude.ai/code/session_01XKxtXPAwtQgFHgmRux2rcF
… widgets

The v3.2 cutover roughly doubled mobile/analysis_options.yaml's rule set and
"Run Dart Code Analysis" runs `dart analyze --fatal-infos`, so an info is a
failed build. Two lines this branch adds would trip it:

- `always_put_control_body_on_new_line` (raised to a warning in analyzer.errors)
  on the presence-rows early return in places_picker.page.dart.
- `unnecessary_parenthesis` on the parenthesised ternary condition in
  deep_section_scaffold.widget.dart. Inside that `cache != null` branch the
  predicate is exactly the `isEmpty` local computed three lines above, so this
  reuses it instead of restating it — shorter and provably identical.

Both are behaviour-preserving. Kept as one commit on top rather than folded into
the feature commits, so the replayed history stays faithful to what was reviewed.
Full regen pass after the replay, run from this worktree (never via a `mise //:*`
task, whose `//:` prefix resolves to the MAIN checkout and would have rewritten
the wrong tree):

  pnpm -C packages/sdk build && pnpm -C packages/plugin-sdk build && pnpm -C server build
  node server/dist/bin/sync-open-api.js
  oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas ...
  node server/dist/bin/sync-sql.js   (against a throwaway DB, not the shared e2e one)

Only fetch-client.ts moved. The OpenAPI spec and all 63 generated .sql files —
search.repository.sql included, with both location-presence facet queries — came
back byte-identical to what the replay produced, so the mechanical conflict
resolutions in those generated files were already correct.

The one delta is placement, not content: resolving the fetch-client conflict by
hand put `LocationPresence` after `SearchOrderField`, while the generator emits it
before. Same two members either way; the generator wins.

mobile/openapi/ is deliberately absent — the Dart client is generated at build time
into the gitignored mobile/generated/openapi/ since the v3.2.0 cutover.
…lints

SearchFilter is a freezed class now, so the cascade assignment
`SearchFilter.empty()..location = ...` no longer compiles — the file failed
to load and took the whole time-buckets suite with it. It uses copyWith,
matching the idiom every neighbouring test already follows.

The rest was newly-enforced lints: the generated locationPresence Optional
holds a nullable, so both null assertions on it were redundant, and the
haptic call in the two places surfaces needed the `unawaited` its siblings
in the same file already had.

Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
…Presence guard

main re-described `timeBucket` as YYYY-MM-DDT00:00:00.000Z (#1093) while this
branch wrapped the same schemas in the `locationPresence` exclusivity pipe.
Keep both: the guard now sits on a base schema that carries main's format.

This branch has not been deployed

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

Labels

changelog:feat Feature change for changelog documentation Improvements or additions to documentation 📱mobile 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants