shared gains the response schemas these routes answer with (rows in their
storage spelling, as the routes have always sent them) plus the common
success and empty answers, all exported for the client to type its calls
with. Every JSON handler in the four controllers carries its
@ResponseContract; the file download, which streams, is marked exempt. The
files e2e fixture table gains the note_id column the real one has.
The decorator records the @trek/shared response schema a route answers
with. Under NODE_ENV=test it adds an interceptor that parses every response
and fails the request with a logged 500 naming the handler and the issues,
so the e2e and integration suites catch a response that drifted from its
contract; in production nothing is added to the request path and the body
is never rewritten. lint:response-contracts holds the handlers still without
one to a per-file baseline that only shrinks, and runs in server-quality.
The SDK accepted any number as apiVersion while the host refuses anything
but a positive integer no newer than its plugin API, a false green for an
author. The manifest patterns, sets and caps now live in the host's
protocol/manifest-rules.ts, and gen-plugin-facts writes them and
PLUGIN_API_VERSION into the SDK, so check:plugin-facts covers them. One
manifest corpus is judged by both validators in a server test (the SDK may
only be stricter, case by case), and a typecheck test holds the SDK's plugin
API types to the host child's copy, which gains rawBodyBase64.
toMinor, sumMinor, splitEqualShares and currencyDecimals move to
shared/src/money. The budget service and its MCP tools call them instead of
private copies, and the share table both sides were pinned to becomes the
spec of the shared function. Stored values and every computed figure stay
the same.
Prettier on every new or moved file that was not formatted, so lint:format
holds them; the files taken off the format baseline are the ones that moved
or were formatted on the way.
The module comment no longer spells an @InjectRepository call the scanner took
for a foreign Users injection in nest/database, and journey's Users entry is
gone now that it reads the Immich flag through UserImmichRepository.
The reader left places.service with four strictNullChecks errors that a new
file may not carry. Declaring fetchPage's result as a page or a list import
error lets the 'error' check narrow it; the places and maps entries drop off
the strict baseline.
Non-null assertions become explicit guards, the two demo-gate loops collect
their answers with Promise.all, and the MapsService imports the maps tests no
longer use are gone. The src counts the refactors lowered are taken off the
warnings baseline.
A domain injecting the repository of a table another domain owns couples
the two below the service layer, and nothing counted it. The new
foreignRepositories rule reads every @InjectRepository under src/nest,
looks the entity up in scripts/repository-owners.json (an injected entity
without an owner stops the run) and holds each domain/entity pair to the
baseline, which only shrinks: 209 pairs today.
VacayMcp injected AuthService only to call getCurrentUser for the caller's
username and email before sending an invite or sharing the calendar, which
kept VacayModule importing AuthModule. UsersRepository.findUsernameEmail
answers the same two fields (and undefined for an unknown id, which the
tools already refuse as 'User not found.').
AddonsService read the transit backend through transit/transit-provider.ts
and built photo-provider endpoint paths with memories.helpers, so the addons
domain, which nearly every domain imports for its gates, depended on
transit and memories. Both are free functions over a repository or plain
strings; they move unchanged to common/, where the transit and admin sides
read them too.
The websocket gateway injected JourneyDomainService to decide who may join
a journey's book room, so the realtime domain, which nearly every domain
imports for its broadcasts, depended on journey and closed a cycle through
all of them. It now injects a JOURNEY_ACCESS port declared in realtime;
JourneyAccessModule (global, imported by AppModule beside the gateway
module) binds it to the same JourneyDomainService method.
The gateway imported hostVersion from the plugin install code to put the
version in its welcome frame, which made the realtime domain, imported by
nearly every other, depend on plugins. The running version (image VERSION
or APP_VERSION, else package.json) is app config; runningVersion() holds it
and the plugin host-compat checks call the same function.
NotificationsController, SettingsController, OidcController and the packing
template controller each imported their body DTO from admin/admin.dto.ts,
and AdminController imported the same four without using them. Those
imports tied the notifications, settings, packing and oidc domains to the
admin domain and through it to everything admin depends on. The classes
move unchanged (same names, same @trek/shared schemas) into each domain's
own dto file.
Every domain's *.rpc.ts was written against rpc-kit, rpc-params, rpc-errors
and PluginGuards, and every domain module imported PluginGuardsModule, all
of it inside src/nest/plugins, while PluginsRuntimeModule imports those same
domains so their handlers exist at boot. That made the plugins domain part
of every cycle. The kit imports no domain but the protocol tables and
permissions/addons for the guards, so it moves to src/nest-rpc beside
src/nest-mcp, with its tests, its README and its extraction rule; the
runtime module keeps its domain imports, which the boot-time coverage check
of the wire surface needs in any subset app.
JwtAuthGuard, OptionalJwtAuthGuard, CookieAuthGuard, AdminGuard, the global
and MFA guards, @CurrentUser, @Public, JWT verification and the ephemeral
token service lived in the auth domain, so every controller that checked a
session depended on the whole account module and its imports. They import
no domain themselves; under src/nest/auth-core they are shared kernel like
common/, every importer points there directly, and AtlasModule and
NotificationsModule stop importing AuthModule, which they only did for the
demo gate. lint:boundaries counts auth-core as kernel: 57 fewer cyclic
domain edges, the largest cycle shrinks from 46 to 40 domains.
Every write tool used to open with its own demo check, 196 copies across 30
tool files, most of them injecting AuthService for nothing else. The
registry now takes a toolGate (built from the container) that runs before
each registered tool handler, and TREK's gate refuses a demo account on
every tool not annotated read-only with the same canned result. The
per-tool checks, the AuthService injections, AuthService.isDemoUser and the
AuthModule imports that only fed them are gone; ESLint refuses a demo check
in a tool file, and a registry-wide test calls every write tool as the
demo account.
UserImmichRepository, UserSynologyRepository and UserAirtrailRepository hold the eighteen connection statements UsersRepository carried, on the same users table and with their statements unchanged, provided through a small module as factories over the EntityManager. ImmichService, SynologyService, AirtrailService and JourneyService inject them instead of the account repository. UserRow drops the dead immich_access_token column (the table keeps it), and a shape test pins the account repository's size.
app-settings.registry.ts lists every app_settings key the server reads by name with its shape, default, environment override and flags. The admin form's keys, its masked and encrypted handling and the public config's reads derive from it, isOidcConfigured() replaces the three issuer/client-id checks in AuthService, and OidcService resolves its values through the same rule while still requiring the client secret. Every literal getValue() read goes through readAppSetting()/resolveAppSetting(), and ESLint refuses a new one.
The ICS export reads the stops and notes of all dated days in two statements instead of two per day, the journey's full read and entry list take the source trip titles in one join instead of one read per entry, and reordering a day's stops or sorting it by time writes every position in one CASE update. Results are unchanged: each day keeps its order (the id settles a tie), a missing trip still names null, and an id listed twice keeps its last position.
server/CLAUDE.md now says which API new repository code uses: Kysely for join, aggregate and set-based statements, the entity methods for single-table CRUD, no new QueryBuilder calls. The TrekRepository docstring no longer calls Kysely an escape hatch. lint:query-api counts this.qb()/createQueryBuilder() per repository file against a baseline that may only shrink and runs in the server CI job.
The same file documents the JSON column and timestamp rules from the two changes before.
tsconfig.strict.json is the build config with strictNullChecks and
noImplicitAny on. lint:strict runs tsc over it and holds the errors per file
to scripts/strict-baseline.json (204 in 109 files today): a file may only
lose errors, a new file starts at none, and an entry above its count fails
until --update lowers it. An error outside any file or an unreadable tsc run
fails closed. It runs in the server job; the build itself stays on
strict: false.
shared's lint ran eslint --fix with prettier/prettier as an error, so in
lint-prettier.yml the format:check after it only ever saw files it had just
rewritten, and the server's format scripts pointed at a test/ directory that
does not exist. Now lint is check-only in both (lint:fix applies fixes), the
server scripts cover src/ and tests/, and a lint:format ratchet on the shared
library holds every file outside scripts/format-baseline.json to Prettier in
both workspaces: in the server job of test.yml and in lint-prettier.yml,
which keeps providing the lint check. Formatting leaves ESLint in shared, and
the unused eslint-plugin-prettier goes. Once the tree is reformatted in one
commit, --update empties the baselines and the check is a full prettier
--check.
Server lint ran without --max-warnings or a baseline, so the rules left on
warn could grow without limit. lint:warnings runs ESLint with the server's
own config through the shared tally, fails on any error or inline rule
config, and holds the warnings per rule for src, tests and eslint-disable'd
messages to scripts/eslint-baseline.json, which only falls. It replaces
lint:check in CI. no-await-in-loop, no-non-null-assertion and no-console come
in as warnings held at today's counts. npm run lint no longer rewrites files;
lint:fix does.
scripts/lib/ratchet.mjs carries the tree walk, the CRLF-safe reader and the
baseline handling the client ratchets already use, next to a warning tally and
a format comparison that take ESLint and Prettier from the calling workspace.
server lint:size now runs on it with the same messages and exit codes, and the
libraries have node:test suites that CI runs in the server job.
All 100 dialect helper cases pass on both engines. Of 1402 repository
methods, 11 send one statement Postgres refuses as SQL and 4 send none
the probe can see; both lists may only shrink from here.
The specs written alongside the session, transaction and lifecycle work
still seeded and read with raw statements. They go through the ORM
factories now; the few that test SQL below the ORM carry a test-sql-allow
reason. The raw SQL baseline holds 177 statements in 6 files.
The specs converted to the factories no longer carry raw prepare()
calls, or keep only ones marked with test-sql-allow, so their baseline
entries are dropped. Leaving them in place would let raw SQL grow back
into those files unnoticed.
The specs seeded through the repositories in the commits before this one
no longer carry unmarked raw statements, so their entries leave the
baseline. What stays raw in them is marked with a reason.
The seeding rounds above took the converted specs down to few or no
raw statements, so their entries in the ratchet drop to the new counts,
and the files that reached zero leave the baseline. A file that is
converted can no longer grow its raw SQL back unnoticed.
Two specs move to the exemptions instead, because raw SQL is their
subject: the migration step that reseats booked nights is tested on rows
seeded over the raw connection before the step runs, and the place
region trigger is pinned by raw updates on databases the migrations
alone built.
The server suite seeds and reads its database through some 5,500 raw
better-sqlite3 statements on the sync handle. They restate the schema by
hand, skip the repositories the app goes through and run on SQLite only,
so nothing in the suite could ever exercise the dialect layer on another
engine.
tests/helpers/factories/ adds typed factories per domain (users, trips and
days, places, assignments, bookings, budget, packing, to-dos, files,
journeys, tours, collections, notifications, settings, plugins, tokens,
atlas, vacay, photos, collab) and generic readers (findRow, findRows,
countRows, updateRows, deleteRows) that go through MikroORM in a request
context of their own, against the app's ORM or a createTestOrm handle.
They refuse a write to a read-only foreign-key twin, which MikroORM would
otherwise drop without a word. The README there is the conversion guide.
The categories integration suite and the tags and calendar-feed e2e suites
are converted in full as worked examples; the tags suite also moves from
hand-rolled tables to the migrated snapshot.
lint:test-sql (scripts/test-sql-ratchet.mjs) holds the raw statements left
to a per-file baseline: growth and stale entries fail, files whose subject
is raw SQL are exempt with a reason, and a single statement can be allowed
with a test-sql-allow comment. CI runs it next to lint:boundaries.
The sessions domain owns user_sessions, yet AuthService injected
UserSessionsRepository next to SessionsService and AuthModule registered
the entity in its own forFeature, only so verifyJwtToken could hand the
repository to verifyJwtAndLoadUser.
verifyJwtAndLoadUser now takes a SessionLookup, the two repository
methods the session check uses (findActive, touchLastSeen).
SessionsService answers both, so AuthService passes itself through the
sessions domain's API and drops the repository and the forFeature entry.
The guards keep handing in the repository from their own entity
manager, which satisfies the same type. auth.service.ts lost lines, so
its size baseline goes down with it.
confirm() created the venue's place row in its own transaction and
broadcast place:created before the booking and its cost were written in
a second one. When that second write failed, the item was skipped but
the trip kept a place no booking pointed at, and clients had already
been told about it.
Per item, the venue and endpoint geocoding, the day lookups and the
cost's frozen rate still run first, outside any transaction. The place
row, the booking and its linked cost then go into one transaction
through the new ReservationsService.createWithCostInTx, the core that
createWithCost itself now wraps. place:created, reservation:created,
accommodation:created, the stay mirror and the cost events are sent
only after the commit. Each item keeps its own transaction, so a
failing item still leaves the others imported; confirm() is tagged
@txIndependent for that and leaves the lint:tx baseline.
PUT /api/integrations/memories/immich/settings saved the connection through
one service call and then wrote auto_upload through a second one from
the controller, each committed on its own. A failure between them left
a new connection with the old upload choice.
saveImmichSettings takes the auto_upload flag and writes it with the
connection (or the disconnect) inside one transaction, after the SSRF
check that does the DNS lookup. An absent or non-boolean flag still
keeps the stored choice, a refused URL still writes nothing, and the
response is unchanged. The controller's write sequence leaves the
lint:tx baseline, and the MemoriesService facade loses the
immichSetAutoUpload pass-through it no longer needs. No MCP tool writes
these settings.
TripsService.update rebased the budget onto the new currency in its own
transaction and only then wrote the trip row in a second one. When the
day grid rebuild, the booking resync or the leave shift threw after the
rebase had committed, budget items and settlements stayed pinned and
re-frozen against a currency the trip never took (#1543).
rebaseTripCurrency is split into prepareCurrencyRebase, which fetches
the rates before any transaction opens, and applyCurrencyRebase, which
reads the outgoing currency and writes the rebase. update() fetches
first and hands the plan to updateTrip, which applies it inside the
transaction that writes the row, the leave shift and the day grid.
updateTrip called without a plan (the plugin RPC host) still never
rebases, and rebaseTripCurrency keeps working as before.
The updateTrip docstring no longer claims the row is written outside
the day regeneration transaction. TripsService.update leaves the lint:tx
baseline, and the size baselines shrink with the files.
createWithCost and updateWithCost run the booking and its cost in one
transaction, but the cost sync still caught and logged its own errors
inside it. A price that could not be written therefore committed the
booking edit and left the linked expense on its old figure; only the
category re-file path actually rolled back.
The sync no longer swallows a failing cost write. Both WithCost methods
are its only callers, so the error now reaches their transaction and
the booking and its cost commit or fail together. A booking import
item whose cost cannot be written is skipped like any other failing
item instead of landing without its cost.
syncBudgetOnUpdate created the expense and then set its reservation_id in a
second statement, inside a try that logs and swallows the failure. Running
inside updateWithCost, a failed back-link committed an expense that was not
linked to its booking. The expense is now created through
linkBudgetItemToReservation, which writes the link on the insert, the same
way syncBudgetOnCreate does.
erasePluginUserData deleted the host-side plugin rows and then queued the
plugin erasures as separate commits, and deleteGuest ran it before its own
transaction. When the guest delete failed, the guest kept its account but had
lost its plugin rows, with erasures queued for a user that still exists. The
erasure is now one transaction, and deleteGuest runs it inside the same
transaction as the budget re-split and the user delete, so it becomes a
savepoint of that write. Only DB writes and a synchronous readdir run inside.
Confirming an import created the booking, then froze the FX rate and created
the linked cost in a second commit, so a failed cost write left the booking
without its expense. REST, MCP and the plugin RPC already go through
ReservationsService.createWithCost. The import now builds the cost entry and
freezes its rate first, outside the write since that can fetch rates, then
calls createWithCost and announces the cost after the commit. A rate that
cannot be frozen keeps the booking without its cost, as before. The place a
venue becomes still commits on its own, with endpoint geocoding between it
and the booking, so confirm stays in the lint:tx baseline at 4.
The import created the booking and wrote its external_* link as two commits.
A failed link left the booking in the trip while the import reported the
flight as skipped. The flights are fetched before the loop, so nothing between
the two writes waits on the network: each flight or joined connection now goes
in with its link in one transaction, and the method is marked as one
transaction per imported booking instead of being held in the lint:tx baseline.
The rule that a multi-statement write runs in uow.transactional was
written down but nothing held it. scripts/tx-writes.mjs reads every class
under src/ with the TypeScript parser and counts, per method, the writes
it issues outside a transactional callback: repository calls that build
an INSERT, UPDATE or DELETE, calls to methods that write (on the class
itself or on a constructor-injected one), and a transaction holding
writes as one. A write in a loop counts twice, of two branches only the
larger counts, local helpers count where they are called, and a helper
whose every call sits inside a transaction is not reported. --explain
lists the line of every call that counted.
Today's 102 reported methods sit in scripts/tx-writes-baseline.json and
may only shrink; most are syncs with network I/O between their writes,
plugin lifecycle steps and adapters calling two service writes. A new
method fails CI, and an entry above what its method holds fails until
--update lowers it, the same ratchet as lint:size and lint:boundaries.
Two JSDoc tags mark the deliberate cases with their reason in the tag:
@txStandalone for a write that stands on its own whatever the caller does
(the audit row, notification sends, the booking price mirror, the
assignment journey refresh, the quota retention purge), @txIndependent
for writes complete one by one on purpose (the OAuth token retention
sweep, the notification claim and its release). Runs in CI after
lint:boundaries; documented in server/CLAUDE.md, the root CLAUDE.md and
the wiki's dev setup page.