Skip to content

Merge dev into feat/orm - #2588

Merged
jubnl merged 291 commits into
feat/ormfrom
orm-sync-dev
Oct 6, 2026
Merged

jubnl merged 291 commits into
feat/ormfrom
orm-sync-dev

Conversation

@mauriceboe

@mauriceboe mauriceboe commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Merges current dev into feat/orm. Your architecture is untouched; only dev's behaviour since bf0507ddb is ported onto it.

Review: git show --remerge-diff d7e65623f shows only the conflict resolutions (the +289k is dev's own work since the branch point, mostly help center and i18n). The four follow-up commits are small: git log -p d7e65623f..orm-sync-dev. Merge as a merge commit, not rebase.

What changed

  • Dev's legacy steps 243-258 are 16 MikroORM migrations with your Legacy migration step N markers and your naming, so installs at any legacy step baseline and boot.
  • Dev's new raw SQL became repository methods, multi-statement writes run in uow.transactional, detached work opens withRequestContext.
  • Dev's tests run on your harness with their ids; only dev-side ids were renumbered on collisions.

Fixed on feat/orm itself: lockfiles out of sync after the react 19.3 and plugin-sdk bumps (npm ci failed), lucide 0.577 class names in tests, eslint . out of heap on the runner, no-promise-as-value.test.ts failing under CI=true.

Please confirm

  1. LEGACY_SCHEMA_VERSION 241 to 258 (it was already one step behind).
  2. tsx is a devDependency here, which breaks key rotation in the image (--omit=dev).
  3. src/db/repositories/** coverage pin: 99% branches, 89.83% measured on CI. It already fails on feat/orm (never measured there in CI). This is the only red item in CI; I did not lower the pin.
  4. reseat-booked-nights.ts now carries dev's body and the SQL that dev had put in src/nest.
  5. Budget PUT answers 400 before 404, as on dev.

The other smaller decisions are in the details below.

Verified: typecheck, lint, server/client/e2e suites; every legacy step 242-258 plus a production snapshot (244) booted twice, no refusals or replays, schema identical to dev, production row counts unchanged. CI is dispatched on the branch because test.yml only triggers on PRs to dev/main.

Full report: conflicts, schema table, per-domain port, all 17 decisions, verification numbers, pre-existing issues

Summary

This merges origin/dev (b6115d4) into feat/orm (378b157) and ports every server behaviour dev gained since the branch point bf0507d onto the ORM architecture, which itself is unchanged: no DatabaseService, no db proxy, no services/, no raw SQL in src/nest. Dev's 16 new positional migration steps (243-258) arrive as 16 MikroORM migrations with Legacy migration step N markers, so installs at any legacy step from 242 to 258 baseline and boot. Four follow-up commits fix breaks that already exist on feat/orm itself (lockfiles out of sync with the manifests, lucide 0.577 class names in tests, the server lint step running out of heap on the runner, the rule tester under CI=true), add two coverage tests, and give the new migrations your naming scheme.

Commits on top of origin/feat/orm:

  • d7e65623f Merge origin/dev into feat/orm (parents 378b157 and b6115d4, signed)
  • af533fcce fix(deps): sync the lockfiles with the bumped manifests and follow lucide's icon renames (signed)
  • b653167a2 ci: give the server lint step an 8 GB heap for the type-aware rules (signed)
  • 342d6edd3 test(server): keep the no-promise-as-value rule tester out of single-run mode on CI (signed)
  • 35a7e3d08 refactor(server): name the new migrations after the first seven words of their purpose, like the rest (signed)

How to review

Start with the merge resolution only:

git show --remerge-diff d7e65623f
git log -p d7e65623f..orm-sync-dev

--remerge-diff shows what changed relative to git's own automatic merge: 325 paths (298 modified, 21 added, 6 deleted), all under server/ apart from package-lock.json and one wiki page. Of those, 142 were real conflicts (137 content, 5 modify/delete, recomputed with git merge-tree 378b15716 b6115d48f); the rest are auto-merged dev files that still spoke DatabaseService or sync calls and had to be translated.

Kinds of conflict and how each was resolved:

  1. Modify/delete (5 files). Dev changed files this branch deleted: db/migrations.ts, db/seeds.ts, tests/unit/db/migration-hygiene.test.ts, nest/photos/trek-photos.repository.ts and its test. The deletions stand. Dev's changes went into the successors: the 16 migrations and PhotoProviderSeeder (two Immich photo_provider_fields rows), TrekPhotosRepository.patchCaptureMetadata plus TrekPhotoRegistrationService.recordCaptureMetadata (now returns whether the row learned anything, test TREKPHOTO-006). Dev's only hygiene-test change was an allow-list comment, so it has no successor.
  2. Services where dev added raw SQL against your repository version. The ORM structure was kept. Each dev statement became a named async repository method with the legacy SQL quoted in its docstring and a statement tag in the domain's series (BG88-BG94, JG122-JG127, SH19, TP77-TP79, RPL2-RPL6, AS32-AS34, AT48/AT49, GQ1-GQ3, PL53, ...). Multi-statement writes run in uow.transactional.
  3. Constructor and module wiring. New dependencies are appended last and required (no @Optional()): GoogleQuotaService (MapsService, GoogleTransitProvider), SettingsService (TripsService), PushSubscriptionsRepository (AuthService, AdminService), MikroORM (JourneyPhotoCaptureService, AtlasService). New entities are registered in the owning modules' MikroOrmModule.forFeature.
  4. Sync to async ripple. Dev code calling methods that are async on this branch now awaits them. Two of these were real bugs after the auto-merge: LlmParseService.readsImages/readReceipt did not await llmConfig.resolve (always answered "yes" / passed a Promise as config), and McpResourceDiscoveryController.protectedResource did not await (floating promise).
  5. Tests. Your harness was kept everywhere (snapshot DB, repository factories, createTestMikroOrmModule). Dev's tests were ported with ids and assertions unchanged, made async where the service is. Where a dev id collided with one of yours, only the dev side was renumbered (see Decisions).
  6. Manifests and lockfile. Where both sides moved a range, the higher one won (better-sqlite3 ^13.0.3, undici ^7.30.0 and nodemailer ^10 from dev; compression, unzipper, uuid, ws, zod from this branch). The lock was regenerated from this branch's lock with npm 11.16.0 (--package-lock-only --ignore-scripts), keeping the 34 libc entries and the musl pins. @mikro-orm/sqlite 7.2.1 already depends on better-sqlite3 13.0.3, so the bump leaves a single copy.
  7. Docs. wiki/Real-Time-Collaboration.md keeps both texts.

Auto-merged dev files that needed translation without a conflict: the whole google-quota domain, receipt-scan, Web Push subscriptions, roadtrip-plan.service.ts, backup-archive.ts, and several dev tests that still built new DatabaseService(...) or ran createTables/runMigrations (booking-import, receipt-scan e2e, oidc-local-provider).

Schema

dev appended steps 243-258 to the positional runner. Each is now one migration after Migration20200101040300 (step 242), with exactly one marker. Column steps use addColumnIfMissing; table and trigger steps use IF NOT EXISTS, so every step survives a replay.

Step What it does Migration file
243 users.immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0; two Immich photo_provider_fields rows, inserted only where the Immich provider row exists (also in PhotoProviderSeeder) Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts
244 Trigger trg_place_regions_follow_place (#2527) Migration20200101040500_a_place_that_moves_takes_its_atlas.ts
245 Table push_subscriptions plus idx_push_subscriptions_user (Web Push, #894) Migration20200101040600_web_push_one_row_per_browser_a.ts
246 The step-244 trigger again (dev repeats it for installs that were renumbered) Migration20200101040700_the_2527_trigger_once_more_for_an.ts
247 day_assignments.route_excluded INTEGER NOT NULL DEFAULT 0 Migration20200101040800_a_stop_kept_on_the_day_but.ts
248 packing_items.packed_quantity INTEGER Migration20200101040900_how_many_of_an_item_are_packed.ts
249 bucket_list.region_code TEXT Migration20200101041000_a_bucket_list_wish_for_one_state.ts
250 vacay_company_holidays.fraction REAL NOT NULL DEFAULT 1 Migration20200101041100_half_company_holidays_0_5_covers_the.ts
251 journey_entries.is_draft INTEGER NOT NULL DEFAULT 0 Migration20200101041200_a_journey_entry_still_being_written.ts
252 share_tokens.share_travel_only, share_tokens.share_hide_images (both INTEGER NOT NULL DEFAULT 0) Migration20200101041300_two_narrower_ways_to_share_a_trip.ts
253 budget_settlements.note TEXT Migration20200101041400_a_note_on_a_settle_up_payment.ts
254 Table google_api_usage (day TEXT PRIMARY KEY, calls INTEGER NOT NULL DEFAULT 0) Migration20200101041500_google_api_calls_per_utc_day.ts
255 packing_template_items.weight_grams INTEGER, quantity INTEGER NOT NULL DEFAULT 1, bag_name TEXT Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts
256 journeys.status_override TEXT Migration20200101041700_a_journey_s_state_as_its_owner.ts
257 places.email TEXT, places.opening_hours TEXT Migration20200101041800_a_place_s_e_mail_and_its.ts
258 journeys.photo_location INTEGER NOT NULL DEFAULT 0 Migration20200101041900_a_journey_that_puts_an_entry_on.ts

Steps 244 and 246 share one helper, createPlaceRegionsFollowPlaceTrigger in migration-utils.ts. The folder now holds 260 migrations, 258 of them with a marker (the baseline schema and the ORM-only 040200 carry none).

Entities: new PushSubscriptions and GoogleApiUsage (entity count 125 to 127), the new columns on the existing entities; check:entities and entity-schema-parity pass.

How the baseline handles an install at any legacy step: legacy-baseline.ts derives the step map from the markers, so the only change it needed was the new files. An install with a schema_version row N and no mikro_orm_migrations rows gets steps 1..N recorded as executed; the migrator then applies the baseline schema (IF NOT EXISTS), 040200, and steps N+1..258 in the same transaction. N above 258 still refuses the boot. legacy-baseline.test.ts is pinned to final step 258, with a new case LEGACYBASE-007 (an install at 250 is baselined, not refused). SchemaVersionSeeder.LEGACY_SCHEMA_VERSION moved from 241 to 258 (see Decisions), and a new schema-version-seeder.test.ts (SEEDVER-001/002) derives the expected value from the markers so it cannot drift again.

What was ported, per domain

  • notifications (Web Push, Web Push Notifications #894): PushSubscriptionsRepository with upsertSubscription, deleteOldestForUser, deleteForUserEndpoint, listForUser, countForUser, hasAnyForUser, deleteAllForUser, deleteSubscription, recordSuccess, incrementFailureCount; push-subscriptions.service.ts, WebPushService last in NotificationsService; dev's instance defaults in notification-preferences.service.ts (Server/Admin Settings for Notifications #1536), writes in uow.transactional.
  • auth / admin: password change, reset and admin reset delete push subscriptions through deleteAllForUser inside the existing transactions; user-profile.service.ts gets operatorKeyVariables() (Allow specifying Google Maps/Places API key via environment variable #1881); admin places-google-only toggle is async and audited.
  • google-quota (Add Configurable Google API Limit Option #1582): GoogleApiUsageRepository.callsOn, recordCall (upsert ON CONFLICT(day)), purgeExpired; GoogleQuotaService fully async over AppSettingsRepository; maps and transit await exhausted()/record().
  • maps: nearby search (REST and MCP), Google-only switch via appSettings.getValue, quota wrapper, normalizePlaceWebsite, clampPoiBbox.
  • budget ([Enhancement] Cost/booking linking #2084, [BUG] Costs entries not matching visible data - unsure of calculations of large cost entries. #2525, Write Notes on payments #2340): BudgetItems.listMoneyRows, hasUnfrozenForeign, listUnfrozenForeignCurrencies, listUnfrozenIdsForCurrency, freezeUnfrozenForCurrency, listLinkedToReservation, listIdsByReservation, listIdAndCategoryByReservation, findIdByReservationInTrip, deleteByIds; the same unfrozen-rate methods on BudgetSettlements plus note; BudgetItemMembers.listForTripWithUsersAndPaid; getCurrency now trip-scoped; getPerPersonSummary (BG71) removed because dev replaced it. Freeze-rates route and MCP tool freeze_budget_rates.
  • reservations: remove deletes every linked expense (deleteByIds) in one transaction and returns deletedBudgetItemIds; withFrozenRate resolved before and outside the write transaction; type-change re-files linked expenses.
  • trips: generateDays runs dev's shared planDayGrid over Days.listForDayGrid (TP77) and listDayGridStays (TP78) in uow.transactional; updateTrip returns removedDays; currency default via SettingsService; searchPlaces over Trips.searchPlaceNames (TP79); nextUpcomingTrip, setEndDateTouched, findListShapeById.
  • days: appendDated, DayRemovalService.remove (cancels stays checking in or out, shifts road-trip boundaries via RoadtripDayBoundaries.listDayNumbers/listDayNumbersFrom/moveDayNumber); resyncAccommodationDays seats the stay's stop through night-seat.ts.
  • accommodations: night-seat.ts is now the pure rule (standsAhead, seatAmong, seatHolds, planViaCarry, ...) plus *With adapters over the repositories; DayAccommodations.listIdsCheckingInOrOutOn, listRoadtripStays; DayAssignments.listSeatRows.
  • assignments (Clear an entire day at once #2470, Extended Options for Places in the Daily Itinerary - drive, hike , ... #2532): clearDay over listIdsToClear/deleteForDay, setRouteExcluded, both with REST routes and MCP tools.
  • roadtrip: Reservations.listRoadtripCarriers, listRoadtripUndated, ReservationEndpoints.listRoadtripTerminals, extended DayAssignments.listRoadtripVisits.
  • places: email and opening hours on create/update (REST and MCP), set_place_image_from_file over Places.findActiveTripFile (PL53), country/region in listForTrip, GPX enrichment.
  • atlas: bucket region_code, PlaceRegions.upsertRegionWhileUnmoved, deleteRegionWhileUnmoved, listWithPlaceLocation; PlaceRegionsRepairJob on the cron registrar; public stats next_trip.
  • journey ([Request] Journey entry drafts #696, [BUG/feature] Allow to edit state of journey #762, [Request] Changing photo order #824, Journey Bilder geo tagging #1003): drafts (hidden from the public share, including draft-only photos), status_override, photo_location; JourneyEntries.listPhotoPlacementCandidates, nameLocationIfUnnamed; JourneyEntryPhotos.listPhotoIdsForEntry, updateEntryLinkSortOrder; JourneyPhotoCaptureService.
  • memories / photos ([BUG] Immich integration doesn't support self-signed certificates #2475, [BUG] Journey photo capture backfill drops EXIF GPS and ignores OffsetTimeOriginal (capture time shifted by server TZ) #2512, Improve immich pagination and gallery sorting #1587): Immich self-signed TLS switch (Users.getImmichCredentials, getImmichConnectionPrefs, setImmichSettings, clearImmichSettings), EXIF capture backfill.
  • packing (make list items with quantities #2296, Improve Packing Liste #1131): packed_quantity; template weight, quantity and bag through PackingTemplateItems.listForApply/insertTemplateItem and PackingItems.listExportable.
  • vacay (Company Half Day Holidays #2439): half company holidays: VacayCompanyHolidays.updateFraction, fraction on inserts and reads, VacayEntries.halveForPlanAndDate.
  • share (improved sharing: expand all days, option to share only planned bookings (flights & accommodation) #1712): share_travel_only, share_hide_images; Reservations.listPublicStayPlaceIdsForShare.
  • receipt-scan / booking-import: new controller on Trips.findAccessible; receipt queue in ImportJobsService kept inside your withRequestContext wrap.
  • plugins: Plugins.listActiveNameCapabilities for PluginPoisService; feed poiCategories and searchProvider.
  • backup: backup-archive.ts and boot-restore.ts from dev; handles open through openDatabase() (db/connection.ts, temp_store = MEMORY, [BUG] Trips cannot be deleted #2518), on the better-sqlite3 allow-list; backup.impl.ts left it.
  • oidc, settings, files, calendar, platform, realtime, llm-parse, collections, help, weather, storage, place-enrichment: dev's behaviour came through the auto-merge or needed only awaits and harness changes.

Fixes found on feat/orm itself

These are not caused by the merge; they fail on origin/feat/orm as it stands.

  1. npm ci fails at the root. 7cd9ba3 raised client/package.json to react/react-dom ^19.3.0, but the root overrides and lock stayed at 19.2.6. Evidence: npm ci --dry-run on clean copies of the package files fails on origin/feat/orm with EUSAGE ... Missing: react@19.3.0 / react-dom@19.3.0 / scheduler@0.28.0 from lock file and passes on origin/dev; the GitHub run on the merge commit (37228001564, "Plugin Permission Facts", step npm ci --workspace server) failed with exactly that. Fix: overrides pinned to 19.3.0 and three lock entries updated (11 lines), so React stays single-copy at the root.
  2. plugin-sdk npm ci fails. 7cd9ba3 bumped @clack/prompts ^1.8.1, semver, @types/* and vitest in plugin-sdk/package.json without the lock (lock root still says @clack/prompts ^1.7.0). The branch's own test.yml run 35533378792 failed in Plugin SDK Types (Invalid: lock file's @clack/prompts@1.7.0 does not satisfy @clack/prompts@1.8.1) and Plugin Permission Facts. Fix: lock regenerated with npm 11. With clack 1.8 the typecheck then reported 5 TS2322 in src/cli/ui.ts; orCancel is now <T>(value: T): Exclude<T, symbol> (no casts, runtime unchanged).
  3. Client tests on lucide 0.577. The same commit moved lucide-react from ^0.344.0 to ^0.577.0. 25 tests in 5 files selected icons by 0.344 aliases; three selectors were renamed (triangle-alert, circle-check, ellipsis). iconMarkup.parity.test.ts (identical on base, feat/orm and dev, so already red on feat/orm) now excludes lucide's new generic Icon export by name.
  4. UPLOADS-P16 (GET /uploads/avatars/%ZZ answers 400, not 404). @mikro-orm/nestjs mounts its request-context middleware as app.all('/{*all}'), and Express decodes that param before any handler. Fixed as your note in tests/helpers/test-orm.ts recommends: registerRequestContext: false in app.module.ts and a pathless mikroOrmRequestContext middleware in bootstrap.ts right before app.init().
  5. LEGACY_SCHEMA_VERSION was already one step behind. c9c40b8 added step 242 without bumping the seeder from 241, so a fresh ORM install rolled back to a 242-step release would have replayed the reseat.
  6. Server lint runs out of heap on the runner. The type-aware rules build the src + tests program, and eslint . died with JavaScript heap out of memory at about 4 GB (run 37230603970). The Lint step in test.yml now sets NODE_OPTIONS=--max-old-space-size=8192; the rules are unchanged.
  7. no-promise-as-value.test.ts fails whenever CI=true. typescript-eslint then infers single-run mode, builds the program from the fixture file on disk and ignores each case's code, so all 24 invalid cases report nothing (run 37231065047, Server Coverage). The test landed after the branch's last CI run, so it had never run there. Reproduced in a Linux container: 24 failed with CI=true, 50 passed without. Both testers now set disallowAutomaticSingleRunInference: true.

Decisions to confirm

Each is implemented one way; the alternative is named.

  1. LEGACY_SCHEMA_VERSION 241 to 258. It is the 1-based stamp of the last positional step (same numbering as the markers and finalStep). Keep, or revert to 241 and accept the replay on rollback.
  2. Upgraded installs keep schema_version at N (production: 244), while fresh installs get 258. Rolling an upgraded install back to a positional-runner release replays steps N+1..258 (all guarded; checked by reading them, not by running a rollback). Your LEGACYUP-*-004 tests pin "the legacy marker stays". Should the baseline bump the row to finalStep in the same transaction?
  3. async up() + this.execute() for static DDL in 040600 (step 245) and 041500 (step 254), where you mostly use override up(): void + addSql. You have execute-only precedents (033900, 025000, 035600). Convert or keep.
  4. Frozen helper reseat-booked-nights.ts changed. It carries dev's body (81292d4: one pass, nights ordered by check_in IS NOT NULL, check_in, id), and it now owns the six prepared statements that dev had put in nest/accommodations/night-seat.ts, so src/nest spells no SQL. It imports the pure rule from night-seat.ts. This changes what step 242 does for installs that have not run it yet. Accept, or keep your original body.
  5. Request-context wiring. Production now uses the bootstrap middleware (fix 4 above); the partial harnesses (createTestMikroOrmModule) keep the module default. Should one shared option object move into mikro-orm.config.ts so both use identical wiring?
  6. Budget PUT status order. BudgetController.update runs dev's linkRefusal (400) before your U6 toRowId gate (404), so a non-numeric :id with a foreign reservation_id answers 400 as on dev. One unit pin added. If "parse :id first" is a hard rule, this one handler and its pin revert. ReservationsController keeps dev's 404-then-400 order on purpose.
  7. Detached work wrapped in withRequestContext. AtlasService.cacheRegionsInBackground (your atlas IIFE was deliberately bare) and the three JourneyPhotoCaptureService.schedule* methods now fork their own context, with MikroORM as a required last parameter. Tests ATLAS-CTX-001 and JPCAP-CTX-001..003 fail with the wraps removed. atlas-geocode-request-context.test.ts had its spy on upsertRegion, which nothing calls any more; it moved to upsertRegionWhileUnmoved. Revert the atlas wrap if you prefer it bare (one line plus ATLAS-CTX-001).
  8. Repository methods removed because dev deleted their only callers: DayAssignments.reanchorToDay (with DayAssignmentsKyselyDB), DayAssignments.listLocatedIds, Days.clearDate/isEmptyDay/listTrailingEmptyIds (with DAYREPO-021..024), PlaceRegions.upsertRegion. BudgetItems.findIdAndCategoryByReservation became listIdAndCategoryByReservation (dev's .all, ORDER BY id added for a stable order). Restore any of them from origin/feat/orm if you want them as general helpers. Four methods already uncalled on feat/orm were left alone: OauthClients.findPublicById, PluginEntityMetadata.countForEntity, PluginScheduledTasks.existsForPluginAndName, PluginScheduledTasks.upsertTask. PhotoCaptureBackfillService.schedule has no caller on either branch.
  9. Users.setImmichSettings is one UPDATE again, via a new dialect helper coalesceOverrideWhileSame in sql-functions.ts that renders dev's CASE WHEN immich_url IS ? THEN COALESCE(?, immich_allow_insecure_tls) ELSE COALESCE(?, 0) END. USERSREPO-086 compares it with dev's literal statement over 24 combinations. SQLite only: Postgres will need IS NOT DISTINCT FROM.
  10. Test id collisions. Only dev ids moved, whole dev blocks shifted to stay monotonic: AUTH-DB-036e/f/g to 036f/g/h, DAY-SVC-055..057 to 056..058, RESEAT-011..016 to 012..017, ASG-SVC-042 to 047, TRIP-SVC dev 070..075 to 074..079 and dev 069 to 080, the two dev blocks BUDGET-SVC-DB-044..051 to 070..077 and 078..085. Pre-existing duplicates on feat/orm left alone: ADMIN-025, AUTH-013, AUTH-040 (integration), USERSREPO-072/073, ADMIN-SVC-066, BUDGET-SVC-DB-010, UPLOAD-FILTER-007, PLACE-SVC-019c, TRIP-SVC-059. Duplicated on dev itself and kept as dev has them: ATLAS-015, CAL-045, CAL-046, CAPTURE-016, CAPTURE-017.
  11. Dependency bumps from 7cd9ba3 kept (react 19.3.0 override, plugin-sdk ranges, lucide 0.577). The alternative is reverting client/ and plugin-sdk/ to dev's ranges.
  12. Coverage entry src/db/repositories/** at 99/99/99/99 fails on feat/orm already (see Verification). Add repository branch tests, or re-pin from a green Linux run with scripts/coverage-thresholds.mjs. Nothing was lowered.
  13. shared prettier drift. format:check fails on feat/orm (363 files) and on dev (430). One format-only commit on dev would fix both branches; doing it here would make 430 dev files diverge from dev.
  14. tsx is a devDependency on this branch (dev has it in dependencies). The Dockerfile runtime stage installs with --omit=dev, and its comment says encryption-key rotation runs node --import tsx scripts/migrate-encryption.ts; that would fail with module-not-found in the image. Move it back, or change the rotation path.
  15. Behaviour difference in scheduleForEntry: dev called the sync journeyIdOfEntry before detaching, so a throw reached the REST caller; here fillForEntry awaits it inside the detached chain and logs [Journey] capture refresh failed for entry <id>: (pinned by JPCAP-003b). The entry add has already succeeded at that point.
  16. Smaller conventions, say if you want them changed: no call-site BG tags in budget.service.ts (tags only in repository docstrings); getImmichAutoUpload tagged JV1 (its only caller is JourneyService); BudgetItems.deleteByIds written in Kysely like its neighbour deleteById; insertTemplateItem names each column in .values(); DayGridReadRow.has_plan_items typed number with $castTo; calendar.service.ts legsOf = (r: any) kept as dev wrote it next to your windowSidesOf = (r: any) (type both or neither); GoogleApiUsage.day typed string | null because the generator check requires it; a docstring-only edit in the shipped migration 040300; backup-archive.ts runs two raw prepare() calls (PRAGMA integrity_check, sqlite_master) on the uploaded archive's own handle, not the app DB.
  17. Optional: a committed tests/fixtures/legacy/legacy-v258.sql with an integration test would pin a dev-install upgrade permanently; today LEGACYBASE-007 and the place-regions upgrade cases cover it in unit tests, plus the one-off boot matrix below.

Verification

Local, Windows, on the tree of the first two commits (the last round ran on the index with no unstaged or untracked files):

  • Typecheck: shared, client, server typecheck, typecheck:tests, typecheck:scripts: 0 errors.
  • Lint: server 0 errors, 2189 warnings (every added warning traced to dev's own files: more no-explicit-any in 23 test files, legsOf(r: any), 11 require() in src/index.ts, one unused var); client 0 errors, 1209 warnings; lint:pages OK. check:entities, db:call-graph -- --sync --tx, check:plugin-facts and i18n:parity:strict pass.
  • Server tests (vitest run --coverage --maxWorkers=4, private TEMP): 732 files, 15747 passed, 8 failed, 21 skipped. The 8 are the known Windows baseline: 6 in backup.impl.test.ts (path separators), 1 in auto-backup.test.ts, 1 symlink EPERM in storage/local.driver.test.ts. e2e (tests/e2e, 53 files) is inside that run and passed fully.
  • Client tests: 919 files, 17947 passed, 25 failed, all lucide 0.577 class names (fix 3). After the fix the 5 files pass 229/229. The full client suite was not rerun after the fix, and it ran against React 19.2.6 from the local node_modules, not 19.3.0.
  • Shared tests: 75 files, 957 passed (one i18n-parity load flake on the first run, green alone and on rerun).
  • plugin-sdk (scratch copy): npm ci, typecheck, build clean; 340 passed, 13 skipped (monorepo and registry parity suites).
  • Coverage thresholds: 76 entries, all 73 of dev's present and none lowered; 74 pass, 2 fail, measured before af533fc's two added tests:
    • src/db/repositories/**: branches 89.75 < 99. This one is not Windows-specific: repository files the merge leaves byte-identical already sit at 562/618 branches (90.94%), so the entry fails on feat/orm too. af533fc adds test M1b for the new Immich reads.
    • src/nest/backup/**: statements 96.62 < 97, branches 92.14 < 93, lines 97.78 < 98. The misses concentrate in backup.impl.ts where the 6 Windows-failing tests abort, so it is expected to pass on Linux; not confirmed yet. af533fc adds BOOT-RESTORE-008 for dev's new boot-restore.ts.
  • Boot matrix: 17 legacy databases built with dev's own runner truncated to N = 242..258, plus a production snapshot at 244, plus a fresh file, each booted twice through buildApp() and app.init() on the built server: 38 boots, all OK. For every N the log reports baselining N migrations and applying 260 minus N; afterwards mikro_orm_migrations has 260 rows, the first N are steps 1..N in step order, schema_version stays N, and nothing refuses. The fresh file applies all 260 and is stamped 258. Boot times 6.0 to 7.4 s.
  • Second boot: on every database, no baseline, nothing applied, 260 rows before and after, schema unchanged.
  • Schema comparison (tables, columns, indexes incl. partial, FKs, CHECKs, triggers, views, seed rows): every upgraded legacy-N equals a fresh merged install with 0 differences. Against a fresh dev install there is exactly one difference, trek_photo_cache_meta.cache_key NOT NULL, which is this branch's own 040200 (the same single difference appears between a fresh base bf0507d and a fresh origin/feat/orm). Both triggers exist exactly once on every upgraded database.
  • Production snapshot (244): row counts identical before and after for every existing table (users 5, trips 2, trip_members 1, days 29, places 127, day_assignments 104, day_accommodations 1, reservations 16, budget_items 24, budget_item_members 6, packing_items 38, packing_templates 1, packing_template_items 1, journeys 3, journey_entries 204, vacay_plans 2, visited_countries 1, bucket_list 1, collab_messages 2, trip_files 14), and every pre-existing column byte-identical. Changes: the new columns with their defaults, the two new empty tables, plus runtime writes unrelated to migrations (app_settings +2 for the VAPID key pair, which dev also writes on first boot; plugin_error_log +8 from plugin child processes failing to spawn on Windows). The same snapshot upgraded by dev's untouched runner differs from the ORM upgrade only by the cache_key NOT NULL.
  • GitHub Actions. test.yml triggers on pull requests to main and dev only, so this PR (base feat/orm) shows just the target-branch check; the suite runs by workflow_dispatch on orm-sync-dev.
    • 37228001564 (d7e6562): root npm ci and plugin-sdk npm ci failed (fixes 1 and 2).
    • 37230603970 (af533fc): installs green; Server Types & Lint died in eslint . with JavaScript heap out of memory (fix 6), so the coverage jobs were skipped.
    • 37231065047 (b653167): everything green (both Types & Lint jobs, Plugin SDK, Plugin Permission Facts, i18n, S3 contract against MinIO, Shared, Plugin SDK and Client Coverage) except Server Coverage: 15732 passed, 24 failed, all in no-promise-as-value.test.ts (fix 7). The Windows-only failures above pass on Linux.
    • 37233748621 (35a7e3d, current head): every job green except one threshold in Server Coverage. All 15756 server tests pass (731 files, 22 skipped), including the backup tests that fail only on Windows, and src/nest/backup/** meets its pin on Linux. The one miss is src/db/repositories/** branches 89.83% against 99% (Decision 12), which feat/orm never measured in CI; the pin was not lowered.

Known pre-existing issues left alone

  • Production schema drift, identical after dev's own runner, so not repaired here: users.mfa_backup_codes missing (MFA backup codes may fail on production), users.immich_access_token missing, reservations.accommodation_id INTEGER with an FK to day_accommodations instead of TEXT without FK, budget_items.category default 'Sonstiges' instead of 'Other'.
  • shared prettier drift: shared format:check fails with 6667 errors; every file with new drift relative to feat/orm is byte-identical to origin/dev, and shared/ equals dev apart from package.json.
  • src/db/repositories/** coverage below its 99% branch pin on feat/orm (above).
  • Windows-only test failures (8, above) and plugin child spawn errors on Windows.
  • Duplicate test ids already present on feat/orm, on dev, or on both (listed in Decision 10; about 170 more exist on both parents, mostly integration numbering).
  • settings.e2e (week_start test) logs Audit write failed: no such table: audit_log; the fixture has no audit_log table, the test passes.
  • Reservations.repository.ts listUnplannedPlacesForPublicApi: unused eb lint warning, same line on feat/orm.
  • npm 11's allowScripts gating skipped install scripts during npm ci (better-sqlite3 13 loads from its N-API prebuild; nothing else failed); npm audit reports 15 vulnerabilities in the merged tree (1 low, 6 moderate, 7 high, 1 critical), not addressed here.
  • Stale comments outside the ported files: tests/integration/legacy-upgrade-v242.test.ts header says only post-legacy migrations run at 242 (steps 243-258 now run there too; suite passes 5/5); vitest.config.ts:30 still says "replaying 242 migrations".

sy-records and others added 30 commits September 23, 2026 09:07
The planner keeps the place dialog mounted while it is closed, so its search
block survived a closing. Clearing the field on close (#2465) left the result
list under the empty field, together with the "Search Google instead" line
offering that list's query again, and a pick from that list wrote its name,
address and coordinates over whichever place the dialog was opened for next.

Closing now drops the list, the suggestions, the Google line's query and the
billing session, and cancels a pending autocomplete. A full search or a
suggestion lookup that answers after the dialog closed is discarded instead
of filling the next opening, and no longer ends the session a new opening may
have started.
… lives on the service

The translated hours were taken as soon as any segment parsed. A day the
parser skipped, such as a weekend written 周六、周日, then had no period, and the
client reads a day without a period as closed, so a shop open on Saturday
showed Closed there. Text that did not parse went out as a one-line list,
which the client reads as Monday and splits at the first colon, and which
kept the enrichment from falling back to OpenStreetMap's hours.

amapOpeningToOsm now also reads Amap's documented form (a colon after the
days, remarks in brackets with their own semicolons), 每天, 周末, 工作日, 星期,
到, 、, full-width colons, tilde ranges, 次日, 全天 and 24小时, and a day named
only as closed. It returns null unless every weekly segment reads, and no
hours are sent then.

The autocomplete tips move from a module-level map into AmapTipStash, one
instance on MapsService: a provider is built per request, and the pick and
its details lookup are two requests. Only the tips actually served are kept,
and a stashed tip without a coordinate answers null, so the client falls back
to its text search instead of receiving a place it cannot pin.
…d a choice is saved once it holds

The picker of other ways asked OSRM with nothing avoided while the rail drives a trip
that avoids a road class through Valhalla, so the list had no current road, called a road
the rail was not on the fastest, and choosing it changed nothing. A choice was stored as
one via where the offer strayed furthest, without asking whether the router then drove
it: a point on a ferry was pulled to the pier and the drive went overland, and a way
weighed away from motorways kept the motorway after its one point. An OSRM answer standing
in for a Valhalla that did not respond was cached as the avoided road, unmarked.

The rail now hands out a router per leg with the leg's own mode, avoided classes and
engine, and its days carry the line of every leg. Offers come from that engine, and the
rail's own leg always heads the list as Current. A choice is pinned and proven against
that router with at most three pins before anything is written; a way it will not follow
is not saved and says why, with a hint when it crosses by ferry, and a leg that changed
meanwhile or a device offline writes nothing. The OSRM stand-in is filed under the plain
key only and flags the day as not avoided.
The drive from where one day ended to where the next begins was the one stretch of a
connected road trip that offered no other ways. The band above a card's first stop had no
button on the desk, and the phone did not show that drive at all. A via behind a day's last
stop, where the map already files a point dropped on that drive, was deleted when the day
was reordered under Days, and was left on no stop when that last stop was removed or
dragged up the day. Switching the avoided road classes or the day's profile kept the old
road and minutes on it until a reload.

The drive in now opens the same picker as any leg, on the desk band and on a new row at
the head of the phone's chain that names where it leaves. A choice is proven with the
seam's own router and written behind the last stop of the day before, also when a day with
a single stop lies between. The rule for that via lives once in shared (carriedSeam): it
stays with the last stop while that stop stays last, and is read like any other once it is
not, for the planner's drags and removals as for the server's sorts and seated nights. The
cached answer for a seam now records its mode and avoided classes, so changing either asks
for it again.
…ys so

A ferry between two stops without a clock was seated by the clock alone, which files a
ride with nothing timed before it at the end of the day. The desktop day list stored that
slot, and the road trip drove overland to the far shore and back to the pier before the
crossing. A ride that leaves and lands on the same day, with both terminals located, now
takes the seat between the clocks that adds the fewest straight-line kilometres. One rule
in shared seats it for the day list, the slot the desktop stores, the phone and PDF lists
and the road trip in the browser and on the server. Slots already stored are left alone.

A flight, train, ferry, cruise or bus on no day made no seam and said nothing, while the
map still drew its arc. The rail and the phone tab now name such a booking with a way to
open it, and calculate_roadtrip lists it under undatedRides.
…d say why a way was not taken

The slot the desktop stores for a ride within one day was worked out over the rows the day
list shows, so a hotel it hides on the far shore was left out, the ferry was filed behind
it and the road trip drove there overland before the crossing. It is now worked out over
every stop the drive reads, timed the way the drive times it. An overnight ride no longer
seats its arrival by the slot seeded on the day it left, and opens the day it lands on
unless somebody placed it there. A via behind a day's last stop goes once that stop is no
longer last, for the rail's drags and removals, the list's reorder and the server's sorts
and seated nights alike; some of them used to bend a leg of the day through it instead.

A leg OSRM drew while the avoidance router did not answer is now known as such: the picker
no longer reads it as that router's road, has the leg routed again when its own road is
chosen and says so, and a join that fell back is asked for again and flagged on its card.
A refused way that leaves a class out names the setting that drives it, the ferry hint
covers a crossing that lands the next day, a road another engine timed names that engine,
and the desk bar keeps the keyboard on the chip while a choice is checked and announces
the outcome. A ride on none of the trip's days is no longer said to have no date.
…d a refusal is said once

A via cuts a drive into one routed leg per waypoint pair. Folding them back
onto the stop pair summed the metres and seconds but kept the first piece's
printed texts and end point, and the phone reads the texts first: a drive
into the next day of 3.9 mi in 14 min showed 1.9 mi in 6 min. Every choice
under "Other ways" writes vias, so every choice ran into it. The pieces are
now merged by one shared helper that writes the texts from the sums in the
reader's unit, for a day's own run, for the drive between two days and in
the server's plan; the drive between two days is asked for again when the
unit changes, as a day's run already is.

On the desk a refused way was said in the bar's status line and again in a
toast laid over that line. The desk keeps the status line; the phone, whose
bar has no room for the sentence, keeps the toast.
…ne suites do

The desk helper waits for road trip mode, which never turns on at phone
width, so the case timed out before it asked anything. It now waits for the
drive tab, as the phone feed suites already do.
…omes without them

A leg a routing provider answers without its from and to points threw in
the merge, and the day it belonged to never finished routing. Such a leg now
keeps the first piece's ends; the texts are still written from the sums.
Signed-off-by: hiro-nikaitou <vieteviete@proton.me>
The trip's places went into the field's search results as they were. A place
without a pin was picked as 0,0, because Number(null) is a finite 0, and a
highlight left over from a longer search could point past the shorter list
and throw on Enter. The phone's form, the train stations and the car stops
were not offered the places at all.

The places are now prepared once per form (named, with a real coordinate,
each once) and handed to every location field of the manual tab, on the
desktop and on the phone. The field shows them while it is empty or holds
fewer than three characters, narrowed by what was typed, and derives that
list rather than writing it into the search results, so a late search answer
cannot replace it and a place already picked does not reopen it.
Which stay a day wakes up in and which one it sleeps in was decided in the
client's Days view only. The rule, with the day order, the stay range and the
check whether the hotel is the edge stop itself, now lives in shared under
day/stay-bookends, typed on the few fields it reads, so the road trip in the
browser and on the server can make the same choice as Days. The Days helpers
re-export it unchanged, and their tests pass untouched; nothing behaves
differently.
The drive learns what a booked night at the edge of a day is, while nothing seats one
yet. One rule in shared, seatNightBookends, picks the stay a day wakes up in and the one
it sleeps in the way Days does, over real nights and in id order, and puts each hotel at
the edge of the day as a stop of its own that belongs to no assignment.

The schedule, the day window, the spill into the next morning, the sums and the connected
days read a night spent at one hotel as a leg going nowhere, never cut or postpone the
drive to tonight's hotel, and pass the hotel over wherever a stop is looked up by its
stored index. That lookup now also puts a place added behind a terminal where it fell.

The rail and the phone chain draw the hotel as a row of its own that opens the booking or
the stay, offers no other ways and takes no via, in every language. Until a trip switches
it on, every plan is the one stored.
…before one ends there

A trip can now switch on roadtrip_hotel_bookends, a shared driving preference that is off
until somebody sets it, so every trip drives exactly as before. Switched on, the planner in
the browser and calculate_roadtrip on the server seat the hotel slept in at the start of the
next day and tonight's hotel at the end of the day, both through seatNightBookends over the
trip's stays in id order, with the earliest booking linked to each. A linked booking changes
the hotel row and asks the router nothing.

The switch sits in the desktop driving settings under the route line, and the phone's
driving figures carry the same switch as the one control they offer; both write through the
planner's own preference save, so a flip offline queues like any other. get_roadtrip_context
reports the stays, calculate_roadtrip previews the switch through its settings, and the
settings, corridor and day boundary tools say what it does and that missing means off.
The road trip page gains a section on the new switch under the route line:
off by default, shared by the trip, and on the phone the one control in the
driving figures. It says how the hotel rows read, what they count towards,
what a click opens, when a side of the day gets none, and that the drive to
and from the stay takes no via and no other way. The daily travel times, the
settings table, the phone section, the MCP table and troubleshooting point to
it.

The accommodation and planner overview pages name the switch and that it
leaves Days alone, and the MCP tool page describes the bookend stops of
calculate_roadtrip, the stays of get_roadtrip_context, the corridor and the
ignored boundary over a booked night.
…s right

A hotel row now takes its position and name from the trip's place instead of the stay
row, which the planner fetches again only when a stay is edited. A moved pin no longer
seats the hotel at its old spot while the server plans from the new one. The drive out
of the morning hotel goes the way the first place is reached from it, the mode Days
draws that leg in, and it is set in the shared rule so the browser and
calculate_roadtrip agree.

On the rail, a hotel row that took in the morning marker starts the line. A day that
only drives from one stay to the next shows no stop count and takes a dropped stop as
its first. The hotel keeps its pin on the desktop map, and the switch explains itself
under its label and to screen readers. On the phone, the note about figures set on the
desktop now sits above the switch it does not cover. The hint and the refusal on the
hotel's drive say what they mean in all 23 languages. The via tools and the wiki say
that a via into a booked night is kept but not used, the wiki states the overlap rule
Days applies, and the arrived-from branch of the via anchor has tests.
…takes its via

The check-out row showed the room's last hour and, beside it, the time the
plan leaves the hotel, which can be hours later when a place further on is
pinned. The row now says "Leaves after check-out" when it does, on the
desktop and on the phone.

A click on a road the day drives twice, out of the hotel in the morning and
again between two of its own places, went to the first pass, the drive out
of the hotel, and was refused. The second pass now takes the via, and where
yesterday's drive into the hotel and today's leg share a road, today's leg
does. A road only the hotel's drives use still takes none.

The wiki says both.
…e, and a failed import does not look like a success

Gemini behind the OpenAI-compatible provider decodes against the JSON schema
TREK sends and fills declared properties only. reservationFor was an open
object without any, so the model left it out: a Booking.com stay came back
with its dates and price but no hotel name, and the mapper had to drop it
(#2477). It is the same cause as #1638, whose fix only made the JSON readable.

The schema now declares every field the prompt names inside reservationFor,
for venues, flights, trains, buses, boats, events and rental cars, at most two
levels deep, and requires it; the car desks at the root get the same venue
fields. Every object stays open to keys the prompt does not name.

On the cloud AI path an answer is cleaned up before it is mapped. Exact
duplicate nodes are dropped, so one stay is one booking. priceCurrency is read
as an ISO 4217 code ("EURials" is EUR, a lone symbol maps to its code); when
the model names none, a priced node takes the currency of the document's
total, and otherwise the field goes instead of saving a code nobody can
convert. The kitinerary path is untouched.

The import upload decodes file names as UTF-8 like every other upload route.
"Bestätigung.pdf" reached the warnings as mojibake, and the review could not
attach the file to the booking it created, because it matches on the name.
The OpenAI-compatible client logs which response_format the answering
attempt carried, metadata only, so the next report shows whether the schema
reached the model at all.

The background import card showed a green tick beside "no reservations could
be extracted". A parse that found nothing now wears the warning mark instead;
it is the same card on the desktop and on the phone.
…, as the other providers can

Synology Photos, AirTrail and Dawarich each have a switch to trust a
self-signed certificate; Immich had none, so every request to such a
server failed with a bare "fetch failed" (#2475).

Immich gets the same switch, per user and off by default:

- users.immich_allow_insecure_tls (migration 243, column guarded) and a
  settings row labelled with the existing skipSSLVerification string, so
  no new translations. The settings card on desktop and phone draws it
  from that row.
- Every request to the user's Immich (status, browse, search, asset info
  and proxy, albums, upload) passes rejectUnauthorized from the switch.
  Only 1 counts as on. A shared photo follows its owner's switch, since
  it goes to the owner's server with the owner's key.
- Settings PUT and the connection test take allow_insecure_tls as a
  boolean. Absent on a save keeps the stored choice, so an older client
  cannot clear it; disconnecting turns it off again.
- A failed request names its cause, e.g. "fetch failed (self-signed
  certificate)", instead of only the undici message.
- The upload mirror gets a timeout: the journey upload waits on it.

Fresh installs also never showed "Mirror journey photos to Immich on
upload": migration 112 added that row only where the Immich provider
already existed, and on a new database the seeds create it after the
migrations. The row is now in the seeds, and migration 243 adds it where
the provider exists without it.

Wiki: Photo-Providers.md documents the switch.
… to the next

A day that checks out of one hotel and into another, with nothing else
planned, falls back to a drive from the first hotel to the second
(#1297). A flight, train, ferry or coach booked on that day is the move
itself, but one saved without its stations leaves no waypoint behind, so
the day map, the trip overview and the phone plan all drew the full road
between the two hotels, and the phone showed it above and below the
flight (#2476). With its stations the day was already right.

- The fallback drive between the two hotels is no longer drawn when any
  such booking is on the day, located or not. A day without a booking
  keeps it. No line beats a wrong one.
- The phone plan takes the evening hotel leg from the last drive that
  reaches the hotel, not the first, so an earlier drive to the same spot
  no longer stands in for the one that closes the day.
- Google Maps and CoMaps exports of such a day, which could only ever
  describe that same drive, return nothing, and on the desktop and the
  phone the two buttons are left out whenever fewer than two stops
  remain, instead of doing nothing on a tap.

A hotel stop planned by hand ahead of the flight already sorts behind
it through the ride seating of #2474; a test now pins that for the days
view as well.
… sent

The share dialog stored the pick as String(value) while its options are
keyed by numeric user ids. CustomSelect looked the pick up with a strict
comparison, found nothing and left the trigger on its placeholder, although
the Invite button was already enabled and the invite went through. The
phone opens the same dialog from the Mehr sheet, so it showed the same.

CustomSelect now compares the held value and the option values as text, so
a caller holding "5" finds the option keyed 5 and the other way round. A
held null or undefined still matches nothing. onChange keeps handing back
the option value unchanged. No other caller mixes the two kinds today; the
place form had worked around the same trap by keying its options as
strings, and its comment now says why the string stays.

Tests: CustomSelect matches across both kinds and marks the pick in the
list, the desktop dialog shows the picked name and clears after the invite,
and the phone share sheet does the same through the real dialog.

Refs #2478
…editable like in a trip

A saved place always had price, currency, website and phone columns. Saving
from a trip, the file import and the copy into a trip all carried them, but
neither editor showed them and the update contract did not know them, so the
validation pipe stripped them from a PATCH and the MCP tool refused them as
unknown keys (#2471).

- shared: collectionPlaceUpdateRequestSchema takes price (not negative,
  nullable), currency (three letters, trimmed and upper-cased), website
  (http or https, as on save) and phone (trimmed, capped at 60 like the file
  import). The save schema is unchanged.
- server: updatePlace writes the four fields inside the existing
  transaction. update_collection_place spreads the same shape, so REST and
  MCP take and refuse the same values; its description now names the price
  and asks for the currency with it.
- client: the desktop detail sheet and the phone sheet edit price with a
  currency picker, website and phone, and show them in read mode (price only
  above zero, phone as a call link, website as a link chip through
  safeHttpUrl). Both shells share one hook, useCollectionPlaceExtras, which
  sends only the fields the user changed and always pairs a price with its
  currency, since a list has no base currency. An amount that does not parse
  marks the field and holds the save.
- i18n: collections.price, collections.priceHint and collections.phone in
  all 23 locales; the website label reuses places.formWebsite.
- wiki: Collections and MCP-Addon-Tools name the new fields.

Tests: schema spec, COLLECTIONS-SVC-128 to 131, controller pipe parity,
update_collection_place with the new fields, COLLECTIONS-E2E-085 to 087,
FE-COMP-COLEXTRAS-001 to 012, FE-COMP-COLDETAIL-049 to 054 and
FE-MOB-CPLSH-042 to 046.
…import fixes

Saved place price (#2471)
- A price above zero that would be left without a currency is refused
  with a 400, over REST and through update_collection_place alike. A list
  has no base currency, so such an amount was read in each viewer's own
  default currency and went into a trip as the trip's. A price sent alone
  keeps the stored currency, and a zero price or clearing both fields
  still goes through. The check sits in the service because the MCP tool
  spreads the contract's shape, where a refine would never arrive.
- An older row that holds an amount without a currency shows it as a
  bare number instead of in the viewer's own currency.
- The Hungarian hint uses the informal register of its file, which is
  now prettier clean.

Immich certificate switch (#2475)
- The switch trusts one server. Saving a different URL without sending
  it turns it off; saving the same URL keeps it as before. The wiki says
  so.

Moving day (#2476)
- Google Maps and CoMaps are left out only when there is nothing to hand
  over. A day with a single stop opens it as a pin again, on the desktop
  and on the phone, as it did before.
- Desktop: a moving day whose flight or train has no stations shows no
  route tools, since there is nothing left to draw. With stations they
  stay.
- Phone: the drive between two stays on a day without stops or bookings
  shows once at the top instead of again at the bottom.
- The export helpers take the carrier flags as one object instead of two
  booleans in a row.

Import card (#2477)
- The success and error marks use the theme tokens.
- The e2e test names its upload with a made-up booking number.

Tests: COLLECTIONS-SVC-132 and 133, COLLECTIONS-E2E-088,
update_collection_place with a price and no currency, FE-COMP-COLEXTRAS-013
(011 adjusted), IMMICH-TLS-010, IMMICH-103 extended,
FE-PLANNER-DAYPLAN-222 adjusted and 224, FE-MOB-PLTL-051, FE-MOB-PTLM-047,
FE-W4BGT-023 and 024 check the mark colours.
…cked

A connection test against a server with a self-signed certificate ended in
"fetch failed (self-signed certificate; if the root CA is installed
locally, try running Node.js with --use-system-ca)". The advice is meant
for whoever runs the server and cannot be acted on from the settings card,
so it is cut off.

The save button of a collection place stays locked while the price cannot
be read, but looked and pointed like an active one. It is dimmed and shows
the not-allowed cursor now.
… again

Takes back the editable price, currency, website and phone on collection
places from 0df8218 and its review follow-up in a910c2a. Costs are kept
inside a trip only; a collection does not carry them, and #2471 is a
feature request rather than a bug. The collection files are back to how
they were before, including the dimmed save button from f16698b, which
only mattered for the price field.
…tops are hidden in Days

Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel'
is one of the road trip stop types. With "Show in Days too" switched off,
Days dropped every such place from the trip's place list, so a hotel
vanished from the sidebar, from the booking's own place picker and from
the Days map the moment it was booked, and a click on it in the day list
found nothing. Nothing was deleted: road trip mode kept showing it.

Days now leaves out only road trip stops nobody booked. A lodging place
stays, and so does any place a stay or a booking points at, on the
desktop and on the phone. Trips planned on 4.3.1 show their hotels again
without any change to their data.

An imported GPX or KML track is no longer offered as the place of a stay,
in the booking form, the day's hotel picker and the phone's sheets. A
stay that already names a track keeps showing it.
With the Unplanned filter on, a click on a stop in the day list moved the
map while a click on the stay chip did nothing: the map only went to a
selected place it showed as a pin, and the GL map also to one of the open
day's stops, which a stay is neither. Both renderers now look the target
up the same way, the pin first, then the day's stop, then the selected
place itself, so the map goes to the stay whatever filter hides its pin.
A place from the TREK index can carry its website the way Overture has it,
such as fr.wikipedia.org/wiki/Chapelle_Sainte-Barbe_du_Faouët, and saving
it to a collection or a trip failed with "website must be an http or https
URL" over a field the dialog does not show (#2483). OpenStreetMap, Amap and
booking mails hand over websites the same way.

Every place a website comes in from outside now goes through one shared
helper, normalizePlaceWebsite: the index client and its nearby search, the
Overpass and OSM detail mappers, Google search and details, Amap, plugin
search and the booking import. It completes a bare or protocol-relative
host to https, keeps http and https as they are, and drops every other
scheme and anything that is no address. Only the scheme is added, so an
accented path is stored as written.

placeWebsiteSchema completes a bare host the same way before its check, so
an older client, a cached offline result, an MCP agent or a plugin is no
longer refused, while javascript:, data: and mailto: still are. The REST
controller, the plugin RPC and the enrichment now store the parsed value
instead of the raw one, and create_place_accommodation takes the website
through the same schema as create_place. safeHttpUrl still links only a
value already stored in its final form, so nothing changes on screen.

Tests: SHARED-WEBSITE-001 to 012, SHARED-PLACE-030 to 034,
SHARED-COLLFILE-2483-01, COLLECTIONS-E2E-089 and 090,
PLACES-E2E-2483-01 and 02, PLACES-CTRL-2483-01 (the louvre.fr case
adjusted), MCP-PLACES-2483-01 to 03, MCP-ACCOM-2483-01, MCP-COLL-2483-01,
PLACES-RPC-2483-01, MAPS-2483-01 to 05, MAPS-POIS-015, MAPS-GERS-010,
AMAP-012b, TREK-PLACES-2483-01, PLUGIN-SEARCH-2483-01,
BOOKING-IMPORT-2483-01, ENRICH-052e, ENRICH-102 extended, ENRICH-120 adjusted,
FE-UTIL-SAFEURL-007.
mauriceboe and others added 27 commits October 1, 2026 16:59
…navigation without a second heading; white admin page
…user defaults and notifications get pages of their own
…e release's new tasks, texts and translations to match
- Make the “Manage preferences” text in notification emails clickable.
- Link directly to `/settings?tab=notifications`.
- Preserve the localized link text and existing email styling.
- Add a regression test for the generated preferences URL.
…t line breaks

overflow-wrap:anywhere alone lets a long URL wrap inside a flex row; break-words
is overridden by it and word-break:break-word is deprecated. MarkdownText already
gets the rule from .collab-note-md, so its call sites drop the duplicate.
The minimum-rating filter lived in the desktop sidebar's local state, so it
only ever narrowed the desktop list: the map markers and the phone's places
list ignored it. It now sits in the trip store beside the pool and category
filters, and one module (utils/placesFilter) holds the pool, category and
rating matchers that the desktop list, the phone list and mapPlaces all use,
instead of three hand-copied versions of the same checks.
On a phone the only way to narrow the trip's pins was to leave the map for
the places list, and the rating floor could not be set at all. The phone map
now has a round "Filters" control at the foot of its right-hand stack, badged
with how many filters are on, opening a sheet with the pool (all / unplanned /
planned / tracks), the minimum rating (the desktop list's floors), the trip's
categories including "no category", and a reset. It is hidden on the road trip
stage, which draws its own pins.

The places browser's filter panel gains the same rating floor; its category
rows move into MPlacesFilterControls so the panel and the sheet share one
copy. Everything writes the trip store, so list and map stay in step.
MapTogglePill takes an optional count badge.
The sheet opens over the phone map, and with the default bar glass the map's
place names showed through the pool chips, the rating floors and the category
rows, which made them hard to read at 390px wide. It now uses the opaque sheet
material, like the other menus that open over busy content.
Map-Features gets a "Filtering the places on the map" section: the markers
follow the places list's Show, category and rating filters, and on a phone
the Filters button of the plan map opens the same choices in a sheet.
Places-and-Search says the rating floor narrows the map markers too.
…er hook

The fallback from "Tracks" to "All" once the last track disappears lived in
the desktop sidebar and the phone places browser, neither of which is mounted
while the phone map is in front: the map could sit empty under a filter no
control offers any more. useTripPlanner now owns it, once, for every screen.
MapTogglePill always set aria-pressed, so the phone map's Filters control read
as a toggle although it opens a sheet. An opensDialog option drops aria-pressed
for aria-haspopup="dialog"; the other map toggles are unchanged.
MChip only showed its state through colour. A pressable option renders
aria-pressed from active; the places filter's Show and rating floor chips use
it, and the tests now check the chosen chip by its pressed state.
The header claimed the lists and the map always agree. They apply the same
predicate to the same values, but the map narrows "planned" to the open day
(#2024) and leaves collapsed days' stops off.
…trol

The panel button is named "Filters" like the map control, its badge counts
kinds of filter through countActivePlacesFilters (the pool, shown as chips
above it, stays out), and picking a rating floor clears the selection as
picking a pool does.
…ory matcher

PlacesSidebarHeader uses the UNCATEGORIZED constant, placesBrowserModel stops
re-exporting matchesCategoryFilter (its test imports it from utils/placesFilter),
and the orphan comment in the en places strings joins the places.filters key.
Brings the ORM branch up to date with dev (270 commits since bf0507d).
The ORM architecture stays as it is; dev's changes are ported onto it:
legacy steps 243-258 become MikroORM migrations carrying their
"Legacy migration step N" markers, new raw-SQL services move onto
repositories, and dev's tests run on the ORM harness.
…cide's icon renames

feat/orm raised react to ^19.3.0 and several plugin-sdk dependencies without
regenerating the lockfiles, so npm ci failed on every CI job. The root
overrides now pin react 19.3.0, both lockfiles are regenerated with npm 11,
and the tests that select lucide icons by their old class names use the
current ones. Two tests cover the Immich prefs reads and the boot restore
branch that the coverage thresholds were missing.
…run mode on CI

With CI=true typescript-eslint infers single-run mode, builds the program
from the fixture file on disk and ignores the code each case passes in, so
all 24 invalid cases reported nothing on the runner. Both testers now set
disallowAutomaticSingleRunInference.
@jubnl
jubnl marked this pull request as ready for review October 6, 2026 15:04
@jubnl
jubnl merged commit d2e6d6e into feat/orm Oct 6, 2026
14 of 15 checks passed
@jubnl
jubnl deleted the orm-sync-dev branch October 6, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.