The size check walked src/ and tests/ only, while lint:format and
lint:skips already cover e2e/. The help center fixtures under
e2e/help/fixtures.ts hold 2291 wrapped lines, past the 2000 a test may
reach, and could grow without bound. e2e/ is now measured with the test
limit; fixtures.ts enters the baseline at its current size, every other
file there is within the limit.
A file's layer was the first path segment under src/, so src/types.ts,
the shared types module every layer imports as '../types', got the
layer "types.ts" and no rule at all, while the imports into it already
resolved to "types". It could import a component or a page unnoticed.
A file directly in src/ is now the layer of its name without the
extension, for the importer and the import alike. No file changes its
count: types.ts imports only @trek/shared today.
OAuthAuthorizePage reloads the user on mount and useDashboard's retry
reloads it too. The real loadUser then registers the background sync
triggers, a 30 s heartbeat and window listeners that nothing in either
spec unregisters, so they outlive the tests the same way they did in the
account specs before 27db5dd5e. Both specs now stub the triggers, which
neither is about.
The check only looked at src/ and tests/, so a new spec under e2e/ or a
new script under scripts/ could land unformatted, and the ratchet scripts
themselves did (fixed in the previous commit). It now walks e2e/ and
scripts/ too and takes .mjs alongside .ts, .tsx and .css. The 58 files
there that were unformatted before this branch (the e2e specs and help
guides, the older build and lint scripts) join the baseline, which keeps
only shrinking; JSON baselines stay out of scope.
The checker scripts were written without semicolons, against the
semi: true in .prettierrc, so every one of them failed the formatting
rule lint:format enforces on src/ and tests/. This is prettier --write
over the scripts this branch added or rewrote, nothing else.
help/ holds the help center's screen registry and vacay/ a few date
helpers; both are plain data and code like utils/, yet without an entry in
RULES they could import any view unseen. Neither does today, so ruling
them costs nothing in the baseline. managed/ stays free and the comment now
says why: it is the slot an operator fills with whole screens at build time.
A skip only counted when its first argument was a string or template
literal; anything else was taken for a Playwright run-time condition. So
it.skip(name, fn) in a table loop, describe.skip(TITLE, fn) or
test.skip(true, 'why') switched tests off without the baseline noticing,
and test.fixme was not matched at all.
The condition form is now recognised in Playwright specs under e2e/ only,
and even there a call counts when it declares a test (a body as second
argument), is called bare inside a test or group, or has the literal true
as its condition. Under vitest every .skip counts. fixme is a skip.
Nothing in the tree uses these shapes today, so the baseline stays at 35.
Every ratchet command hands its check to runCli, which sets the exit
code from the check's result and turns a failure of the check itself
(a missing or broken baseline, a directory that is not there) into a
printed FAIL and exit 1. The test runs runCli in a separate node
process, as CI does, and also pins that an unexpected exception still
ends the run with a non-zero code.
login, completeMfaLogin and register start tripSyncManager.syncAll()
and the system notice fetch without waiting for them, and every
sign-in, loadUser included, registers the sync triggers with their
30 s heartbeat. #2613 stubbed the sync in the push spec after it was
caught logging while the worker shut down ("Closing rpc while
onUserConsoleLog was pending"); the same shape was left in the other
specs that sign in for real.
authStore.test.ts ran the real sync on every successful sign-in and
logged DatabaseClosedError from it, and its notice fetch went past MSW
to the network and logged a failed fetch, both after the tests that
started them. The sync is now stubbed there, and the login, MFA and
register tests assert that it was started. The notice store is reset
before each test, since FE-STORE-AUTH-024 left a stubbed fetch behind.
LoginPage.test.tsx signs in through the form, and AccountTab.test.tsx
and MSettingsAccount.test.tsx reload the user after an MFA change; they
now stub the sync triggers (and the login spec the trip sync), which
none of them is about.
GET /api/system-notices/active gets a default MSW handler returning no
notices, so a login or a mounted notice host anywhere in the suite
settles that fetch in the test instead of on the network. The notice
store's own tests still override it.
JourneyDetailPage.test.tsx has carried 35 describe.skip blocks since
April, for contributors, trip linking and the map view, features that
were reworked rather than removed. Nothing stops the next skip, and
nothing stops a stray .only from passing CI while the rest of its file
never runs.
lint:skips reads every vitest file under src/ and tests/ and every
Playwright spec under e2e/ from its syntax tree. A .only on any runner
fails the check, always. Skipped and todo tests (it.skip, describe.skip,
test.todo, the x-shorthands, skip.each) are counted per file against
scripts/skip-baseline.json, which only lowers. A skip that decides at
run time is not a declaration and is not counted: skipIf, runIf,
Playwright's test.skip(condition, 'why') and a bare test.skip() inside a
test.
eslint.config.mjs keeps most rules at warn so that CI stayed green on a
codebase that was never linted, and CI runs eslint without
--max-warnings. Warnings therefore only ever accumulated: 1218 today,
among them 194 unused variables and 144 missing effect dependencies in
app code, the kind behind the stale closures #2610 chased.
lint:warnings runs ESLint over the client with its own config and counts
the warnings per rule, separately for app code and for tests, so the
headroom from fixing a test cannot be spent in app code. Messages that
an eslint-disable comment silenced are counted as a third group, so an
inline disable is no way past the ratchet either. No count may pass its
entry in scripts/eslint-baseline.json, and --update only lowers. Any
error, a parse failure included, fails the check too, so it can replace
the plain lint:check step in CI instead of running ESLint twice.
The offline core only works when the views go through it: component,
feature hook, store, repo, then api or Dexie, with writes through the
mutation queue. A view that imports src/api/ skips the cache, the
optimistic write and the idempotent replay. client/CLAUDE.md said so
and noted that no ratchet held the line, and the number of view files
importing api/client grew from 161 to 190 in two months.
lint:offline lists the 213 files under components/, mobile/, pages/
and hooks/ that import anything from src/api/ for more than types
(api/client, the websocket, the per-domain modules) in
scripts/offline-baseline.json. No other view file may, and a listed
file that stops leaves the list through --update, so the next file at
that path does not inherit the exemption. Type-only imports do not
count: they carry no request.
client/CLAUDE.md describes the rule next to the data-flow section,
including that an online-only domain puts its calls in a store or repo.
Components reach up into pages/ for models and helpers (28 imports in
25 files: the journey detail helpers, the collections, atlas and
dashboard models) and sideways into mobile/ for phone hooks and sheets
(36 in 30 files), and a few stores and a repo import from components/.
Nothing stopped the next one, and every such import ties two trees
together that should move and split independently.
lint:layers reads each source file's imports from its syntax tree
(multi-line imports, re-exports, import() and type imports included,
nothing inside comments or strings) and checks them against a small
rule table: a component imports neither pages/ nor mobile/, a shared
hook no view, nothing under the views (store, repo, sync, api, db,
utils and the other helpers) any view, a repo no store, and the Dexie
schema none of the layers above it. mobile/ and pages/ stay free, since
the phone screens reuse the page hooks by design.
The 80 imports that break a rule today are counted per file in
scripts/layers-baseline.json, and a file may hold no more than its
entry. Type imports count: a type the lower layer needs belongs below
it. Tests are not checked.
client/package.json has had a format:check script, but no workflow runs
it, and 2144 of the 2171 .ts, .tsx and .css files under src/ and tests/
differ from what .prettierrc asks for. Without a formatter gate the line
count in lint:size could also be gamed, and every new file picked its
own layout.
Formatting everything in one commit would bury the history, so lint:format
lists today's unformatted files in scripts/format-baseline.json and only
lets the list shrink: a file outside it, any new file in particular, must
be formatted, and a listed file that has been formatted must leave the
list (--update takes it off and never adds one). A stale entry fails the
check, since it would let the next file at that path in unformatted.
The check runs Prettier through its API with the config that applies to
each file and reads the text with LF line ends, so a Windows checkout
agrees with CI about endOfLine. A file Prettier cannot parse counts as
unformatted, and a missing config stops the run.
rtl-lint compared its own path with argv[1] to decide whether it was
the command being run. Started through a symlink or with a path that
differs in its form, it printed nothing and exited 0. The check now
lives in scripts/lib/rtl.mjs and the command file always runs it.
A broken rtl-baseline.json read as empty, so --update would have
written {} over it. It now stops the run with its reason, like
size-baseline.json.
The rtl-lint-disable marker skipped a line wherever it appeared, inside
a string literal too, and nothing bounded how often it was used. It is
now honoured only inside a comment, and the marked lines are counted
per file against scripts/rtl-disable-baseline.json. Today that is the
four map overlay positions in PlanMapStage.tsx. A side that is geometry
on purpose still gets its marker, but its entry is raised by hand, where
review sees it, instead of the marker quietly replacing the logical
forms.
lint:size counted newlines, so a file could stay under its entry by
joining lines, and the trip planner page already carried one JSX element
on a single 3760 character line. A line now counts once per 120
characters it spans, the printWidth in .prettierrc, so formatted code
measures what it shows and packed code measures what it holds.
The check also covered only .ts and .tsx sources. Stylesheets now share
the 1000 line limit, and tests get a limit of their own at 2000: they
mirror the component they cover and carry fixtures, but the largest are
past 5000 lines and growing with the god components.
The baseline is measured again with the new rule and keyed by the path
from client/, since it now spans src/ and tests/. Entries are higher in
number than before because the metric changed, not because a file grew;
every file is held at exactly what it holds today. New entries are the
ten largest tests, index.css and studio.css, the generated
countryShapes.ts (305 lines of 2600 characters each) and
JourneyDetailPageEntryEditor.tsx, which was just under the old count.
The scanning, baseline reading and lowering move to scripts/lib/ so the
next ratchets share them. A baseline that is missing or misshapen, a
directory that is not there and a scan that finds nothing now fail the
run instead of reading as empty, and the command file always runs its
check: there is no entry point guard left that could skip it.
The open-request-schemas header pointed at scripts/open-request-schemas.spec.ts,
which does not exist; the spec lives in src/. The bracket-notation comment in
the server ESLint config kept a dash that the earlier cleanup on this branch
missed.
The foreign key index check took every index from PRAGMA index_list as a
usable prefix, partial ones included. An index like
CREATE INDEX ix ON c(p_id) WHERE state = 'open' would satisfy FKIDX-001,
yet the cascade lookup WHERE p_id = ? still scans the table, which is the
regression the check exists to catch. A partial index now counts only when
its predicate is IS NOT NULL on the key's own columns, the one form SQLite
can prove from p_id = ?. Nothing in today's schema depends on a partial
index alone, so the result on the migrated snapshot is unchanged.
contracts:open in shared only counts exports named *RequestSchema, but
the name is a convention: 37 body DTOs wrap a schema called *BodySchema,
*InputSchema or plain *Schema, and tags.dto.ts declares its schemas
locally. pluginInstanceConfigUpdateSchema (a record of unknown behind
PluginConfigDto), createAlbumLinkSchema with five open shapes and a dozen
more were never counted, and a new open body under one of those names
passed both checks.
tests/unit/contracts/dto-open-shapes.test.ts walks the other way round.
It finds every class that extends createZodDto under src/, imports it,
and counts the open shapes in the schema the Zod pipe validates with
shared's walk. Each class is held to scripts/dto-open-shapes-baseline.json,
61 DTOs with 101 open shapes today, and a new DTO may hold none. It fails
closed: a createZodDto call that is not an exported class, two DTO
classes with one name, an empty tree or an unreadable baseline stop it.
It runs with the unit tests; npm run contracts:dto-open runs it on its
own and --update only lowers the baseline.
The five DTO classes declared inside roadtrip controllers are exported so
the walk can reach their schemas. The selector test picks up prettier's
wrapping of the cases added before.
openShapes() skipped every schema node it had already visited, so one
z.unknown() instance reused across four fields counted once, and a
baselined schema could gain any number of open fields that share a node
without its count moving. z.map(), z.set() and z.custom() were not walked
at all, so an open value inside them counted nothing.
An open spot now counts once per path it sits at, and the visited set is
only a guard on the ancestor chain, so a recursive z.lazy() still
terminates. The same kind at the same path is listed once, which keeps the
two sides of an intersection from counting a shared field twice. Map keys
and values and set values are walked, and z.custom()/z.instanceof() count
as a custom open shape. The request schemas hold the same 75 open shapes
under the new walk, so the baseline does not change; the spec covers
reuse, recursion and the new types.
MaintenanceRepository, DemoRepository and six seeders are exempt from the
SQL string selectors as whole files, so new SQL text added to any of them
passed lint unseen, and nothing noticed when a file no longer needed its
exemption. The comment promised a list that can only shrink, but nothing
held it to that.
The selector test now works out which files under src/db/repositories and
src/db/seeders the config really exempts and compares that set with a
pinned table, then runs the SQL selectors over each exempt file's source
and compares the hits with the count pinned for it (23 today). One more
statement fails, one fewer asks for the pin to come down, and a file at
zero has to leave the ignores. Per-line disables would have done the same
job but add suppressions the codebase is meant to lose.
The outbound-timeout selectors flagged safeFetch(url, undefined,
{ timeoutMs }) and safeFetch(url, { headers }, { timeoutMs }), although
timeoutMs is the deadline over the whole exchange that server/CLAUDE.md
tells callers to pass. At the same time an init spelled null or void 0
passed unseen, so a guarded call could still go without any timeout.
A call whose third argument is an object literal with a timeoutMs key is
now exempt, and undefined, null and void 0 (also behind an as cast) all
count as no init. The cast forms pin the inner node type, because esquery
compares String(value) and a missing name would otherwise read as
'undefined' and flag every cast init. The selector test covers both
sides.
The repository rule that keeps dialect SQL in sql-functions.ts only
matched raw() and the sql tag, so a SQL string handed straight to the
connection, em.getConnection().execute('INSERT OR IGNORE ...'), passed
unseen, and the seeders sat outside every repository rule. A new block
refuses a string or template whose text starts like a statement as the
first argument of execute, run, all or get in src/db/repositories and
src/db/seeders; a result mode such as qb.execute('run') stays legal.
The files that already hold such SQL are named one by one so the list
can only shrink: MaintenanceRepository (PRAGMA, VACUUM INTO),
DemoRepository and six of the seven seeders.
The selector groups are named once in eslint.config.mjs, and the
selector test now also checks the new block per path, its exceptions,
and that raw() stays forbidden in repositories.
journey-gallery-order.ts exported raw SQLite ORDER BY text from
src/nest that no production code read any more since the gallery reads
moved onto JourneyPhotosRepository. Two parity tests still compare the
Kysely rebuild against it, so it moves to tests/helpers as their oracle.
The ESLint rule from #2610 that requires a signal on outbound fetches
only matched the platform fetch. The guarded wrappers hand it a spread
init, which the rule trusts, so safeFetch(url, undefined, options) passed
lint with no timeout, and the dispatcher left undici's five-minute
default in place. Nothing capped the body either. Two callers did exactly
that on hosts TREK does not control: the Synology thumbnail read (a
user-configured NAS) and the place enrichment photo download (a URL out
of third-party place data), both buffering the whole answer.
The wrappers now carry defaults. A call without a signal waits 30 s for
the response headers; the body stays under undici's idle timeout, so a
proxied video that pauses with its player is not cut off. timeoutMs adds
a deadline over the whole exchange and joins the caller's signal, once
for the whole redirect chain. Every body is capped at 64 MB, refused up
front when the declared length is larger and failed mid-read otherwise;
callers pass a tighter maxBytes, or null where the body is streamed on
(pipeAsset, the doc-sync transfers that count their own bytes, the
Ollama pull).
The two call sites get a signal and an 8 MB cap. The lint selectors now
cover safeFetch, safeFetchFollow and safeFetchAdminConfigured as well,
including an explicit undefined init, and are named once so the
repository block restates the same list. A new test asks ESLint for the
options it applies to a path and runs them over snippets, so a selector
that stops matching fails a test instead of passing silently.
coverage-thresholds.test.ts imported vitest.config.ts, which pulled the
config into tsconfig.tests.json's program; that program compiles as
CommonJS and rejects the config's import.meta, so typecheck:tests
failed. The test now loads the config at runtime. The other new gate
tests get prettier's formatting.
google-quota, managed, mcp-shared, mcp-transport, public-api,
receipt-scan, route-usage, scheduling, school-holidays, tokens and tours
had no entry in the per-domain ratchet, so they sat in the pooled
src/nest/** catch-all, where a domain can lose twenty points behind the
headroom of the files beside it. That includes the cron registrar, the
MCP HTTP transport, the public API and the new tours domain.
Each is pinned at floor(measured) minus 1 as scripts/coverage-thresholds.mjs
prints it, measured over the unit, integration and e2e suites that load
the domain. A full run can only measure higher. mcp-shared (59 branches)
and route-usage (66 branches, 77 functions) measure below the 80 floor
and are pinned where they are; managed holds an empty module.
A new unit test fails when a folder under src/nest has no entry, or an
entry names a folder that is gone, so the next domain cannot land in the
catch-all again.
Migration20200101042200 wrote 86 indexes by hand so that deleting a trip
or a user stops scanning every child table while it holds the single
connection. Nothing kept it that way: the next table with an unindexed
trip_id would bring the scans back, and the entity parity tests compare
index names, not whether a key is covered.
The test reads PRAGMA foreign_key_list off the migrated test database
and requires the columns of each foreign key to lead some index or the
primary key. Today every one does, so the allow-list for deliberate
exceptions starts empty, and a second case keeps that list honest. A
third builds a small schema to prove the check fails on an unindexed
key, a composite one, and an index that holds the column second.
The server's boot gate only checks that a write route has a Zod DTO, not
that the schema constrains anything, and 47 of the 213 exported request
schemas hold an open shape: a loose object, a record of unknown, or an
unknown field. shared/CLAUDE.md already says to narrow those rather than
add new ones, but nothing counted them.
scripts/open-request-schemas.mjs walks every exported *RequestSchema and
counts its open shapes against a baseline of today's 75. A new request
schema may hold none and an existing one no more than its entry;
--update only lowers. The CLI checks the built dist/ for CI, and
src/open-request-schemas.spec.ts runs the same walk over src/ so the
shared test run fails as well. A missing or malformed baseline, or a
dist/ without a single request schema, is an error.
Module boundaries were held by review alone. storage.module.ts documents
a cycle that only surfaced at runtime as "imports[1] is undefined", three
modules exist just to break cycles, and nothing stopped the next feature
from reaching into another domain's helpers.
scripts/import-boundaries.mjs parses every import under src/ with the
TypeScript parser and checks five rules against a baseline of today's
violations: load-time file cycles (none today once type-only and lazy
imports and the two-way MikroORM entity relations are left out), new
dependencies between domains that already depend on each other (262
pairs, one tangle around plugins and auth), shared-kernel files importing
a domain (8), imports of another domain's internals (69) and src/db
importing src/nest (5). A domain's public surface is what the code
already treats that way: modules, services, guards, decorators, types,
constants, the audit logger, the plugin RPC kit and the addon gates.
New violations fail, --update only drops entries, and an unreadable
baseline, a missing src/ or an import it cannot resolve stops the run.
The client has had lint:size since #2612, the server had nothing, and
the server is where the biggest files live: maps.service.ts holds 3231
lines and grew by 200 in a month, and nine more services are past 1100.
scripts/size-lint.mjs carries the client's semantics over to src/ and
scripts/: a file may hold 1000 lines, the 23 that already hold more are
pinned at today's length in scripts/size-baseline.json, and --update
only ever lowers an entry.
A line past prettier's 120 columns counts once per 120 columns it spans,
so joining lines does not make room under the limit. The check fails
closed: a missing or malformed baseline or a missing src/ stops it
instead of passing with nothing checked.
The planner's two time edits of a visit called the time route straight:
saving the place dialog opened from a visit, and removing a visit's time
before a reorder. They skipped the visit repo's check for an older write
to the same visit still in the queue, so with such a write parked or
backing off the new time landed first and Try again (or the next flush)
replayed the older time over it, with no version token to refuse it.
Both now use the store's setAssignmentTimes, which writes through
assignmentRepo.setTimes, queues behind the older write when there is one
and keeps the optimistic update and rollback the other time edits have.
The dialog no longer reloads the days afterwards, since the store already
holds the new time and a reload would show the server's older one while
the new one waits in the queue; a changed day note is set on the visit in
place instead. The retry docs drop the exception they made for these two
paths.
An online edit that has to queue behind an older write to the same entity
starts a flush so it goes out right after that write. When the older write
was already in flight, flush() returned at once on the in-tab flag, and the
running pass had read its pending rows before the new one existed. The edit
then sat in the queue until the next trigger (the 30 second tick, a
visibility change or a reconnect), so collaborators saw it late and a
refresh in that window showed the older server value.
A flush called while a pass runs now asks that pass for one more round,
which it starts once it is done, next to the existing "mine wins" re-run.
Each pass clears the request when it starts, so the extra rounds stay
bounded by the calls made during a pass.
Holding later queued writes behind a parked one only ordered writes that
were in the queue. Online, the repos send an edit straight to the server, so
an edit made while an older write to the same visit, tour or driving settings
was backing off or parked landed first, and Try again (or the next flush)
then replayed the older write over it. Those writes carry no version token,
so nothing refused the stale replay.
The repos of visits, day clears, tours and driving settings now ask the
queue before an online write. While an older write to the same entity is
pending, in flight, parked or a conflict, the new write is queued too, with
the same optimistic Dexie write as offline, and a flush is started so it
goes out as soon as the one ahead of it does. When the driving settings left
the cache since the older write, they are read in first.
Places and packing items keep their direct online path: they send the base
token, so a stale replay is refused with a 409 and becomes a conflict. What
this does not cover, and the docs now say so: a collaborator's change to a
token-less entity is overwritten by the retried write, as by any replayed
offline write of those resources, and the planner's two time edits that call
the API directly are not held. The wording of the parking commit also named
budget items, which never go through the queue.
Keeping archived trips stopped the sync from wiping them, but left nothing
that ever took one off the device. The trip sync never refreshes an archived
trip, and the 7-day rule and the per-trip switch only looked at the active
list, so an archived trip stayed until Clear cache however long ago it ended.
The stale and switched-off rules now also run over the archived trips that
are stored here, with the same eviction as an active trip: its rows go, its
parked changes stay. The offline settings list the stored trips the server's
list leaves out, so an archived trip gets a switch the user can turn off;
before, the switches came from the active list only and could not reach it.
The cross-tab lock was taken with ifAvailable, so a tab that found another
tab replaying returned from flush() at once. Its callers read that as "the
queue is sent": the online trigger then re-seeds every trip from the server
and rehydrates the open ones, and the socket refetches after its pre-reconnect
flush. All tabs see the online event together, so the tabs that lost the race
laid the server's rows from before the replay over the shared Dexie, and over
the optimistic edits still on their way, while the winning tab was mid-flush.
The lock is now requested without ifAvailable. A tab that finds it held waits,
then runs its own pass, which finds nothing or what the holder left behind,
so flush() resolves only after the replay and the re-seed chained onto it
reads the state after it. The holder's requests are bounded by the client
timeout, so the wait is too.
Every open tab shares the account's queue and runs its own flush
triggers, and the _flushing flag only guards its own tab. Two tabs read
the same pending rows and both replayed them, so correctness rested on
the server's replay cache answering the second request, and each tab ran
the temp id remap and the Dexie write a second time.
The flush now takes a Web Lock named after the account's database, with
ifAvailable, so a tab that finds it taken skips the run the way a busy
flush in the same tab always has; the holder picks the rows up. The lock
is released with the tab, so a tab killed mid-flush cannot strand it.
Where the API is missing or refuses the request, the flush runs as
before under the flag alone.
Parking a write the server keeps failing on unblocked its trip, so a later
queued edit of the same budget item, visit, tour or driving settings went
through while the older one waited. Try again then replayed the older
write last and it silently overwrote the newer one: only places and
packing items send a token the server could refuse it with. Before the
parking change a 5xx stopped the whole flush, so this order was never at
risk.
The flush now holds back every pending write whose entity has an older
write parked as failed or waiting as a conflict, and a write that parks
during the pass holds back the rest of that pass too. Other entities and
other trips keep syncing. Try again therefore replays the parked write
first and the held ones after it, Keep mine does the same for a conflict,
and Discard or Keep theirs flush right away so the held writes go out
without waiting for the next trigger. Writes queued against an offline
create that got parked now wait for it instead of failing as an
unresolved temp id.
The trip sync clears every cached trip the server no longer lists, but it
read only GET /trips, which leaves archived trips out. Every sync (login,
reconnect, the periodic run) therefore wiped every archived trip from the
device, pinned ones included, and with it their parked failed changes. A
trip now only counts as gone when it is missing from both the active and
the archived list, and when the archived list cannot be read nothing is
treated as gone that round.
clearTripData no longer deletes 'failed' rows. Stale eviction, a trip
switched off in the storage settings and a trip that is really gone all
go through it, and none of them is the user deciding to give up a change.
A parked change of a deleted trip, or one the user was removed from, now
stays in the queue: Settings > Offline counts it under failed changes and
offers Try again (the server answers 403/404, so it parks again) or
Discard. Dropping it silently would lose the only copy of an edit the
user may still want to look at or redo elsewhere, and keeping it costs
nothing more than a counter the user can clear in one tap.
The rollback section promised that an older image refuses a migrated
database and names the pre-migrate copy. Only images from 4.4 on carry
that check. Going back to 4.3.x, the most common rollback today, gives
neither the refusal nor the pointer, so the copy has to be restored by
hand before the old image starts.
The backfill in Migration20200101042100 marks reminders as sent by UTC
date('now'), while the reworked job counts days in the app TZ, which read
like a seam that sends twice or skips on upgrade day. It is not: the
backfill stands in for the job before it, which matched start_date
against UTC date('now') at 9 AM app time, so UTC is the clock those
reminders went out by, and with one run per UTC day nothing it marks
was left unsent. The only effect is its strict "<": the upgrade day's
own reminders stay unmarked because the boot may precede that day's
run, so a boot after it reminds such a trip once more. One extra
reminder at most and never a lost one, so the shipped migration stays
untouched and the reasoning now sits on the job.
The todo claim's docstring writes its dedup bound as SQL.
Login, password reset and the sign-up checks all match emails
case-insensitively, but the only constraint was the column's own
UNIQUE, which SQLite compares case-sensitively. Two accounts whose
addresses differ only in case (legacy rows, two sign-ups at once) left a
login to resolve to whichever row came first, and LOWER(email) lookups
had no index to use.
A migration adds a unique index on lower(email). An install that
already holds such a pair must still start, so the migration then
leaves the index out, logs how many addresses are shared and by which
user ids (not the addresses), and records email_case_index_pending.
Since a migration runs once, db/email-case-index.ts runs on every boot
while that flag is set: it repeats the warning until the accounts are
told apart, then creates the index and clears the flag. The app-level
checks keep guarding meanwhile.
A write that passes the up-front check and still loses the race is now
refused by the database. The users repository turns that unique
violation into UserIdentityTakenError, and sign-up, admin create and
edit, and the profile update answer it with the same 409 their checks
give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned
the old behaviour and now pins the 409).
The entity generator could not see an expression index (PRAGMA
index_info names its column NULL), so PARITY-009c found a db index with
no entity declaration. Rule 14 carries such an index into the entity as
a named entry with its own statement; Users.entity.ts is regenerated.
The trip and todo reminder ticks sent with `.catch(() => {})` and then
marked the reminder sent whatever happened. A send that threw (a DB error
resolving the recipients) or whose every channel failed (SMTP down, no
in-app) was recorded as delivered and never retried, without a log line.
And because the mark came after the send, two processes running the same
tick (a rolling update overlap, a stray container on the same volume)
both sent.
Each reminder is now claimed first with one conditional UPDATE through
its repository: reminder_sent_for is set only while it differs from the
start date (and the start date is still the one read), reminded_at only
while it is empty or older than the 20 hour dedup window. Only the
process whose update lands sends. If the send throws, or it attempted
in-app and channels and none went out, the claim is given back, the
failure is logged, and the next tick tries again until the trip starts
or the todo falls out of its window. A send nobody wanted (everyone
opted out) stays marked, since a retry would reach no one.
For that, NotificationsService.send() now reports what it attempted and
what was delivered; a channel that rejects or answers false counts as
not delivered. Existing callers ignore the result.
The legacy-upgrade suites stopped at legacy-v242, while v4.3.3, the
release every 4.x install will come from, left schema_version 244. The
most common upgrade path only ever ran against synthetic step maps.
legacy-v244.sql is that release's own runner booted once on an empty
file, plus synthetic rows across the main tables: users (one with a
mixed-case email), trips with a member, days, places, assignments with
the canary times, a booked stay, a flight with endpoints, budget items
with splits, packing, todos, notes, file metadata, tags, a journey with
an entry and a notification. scripts/build-legacy-fixture-v244.mjs
rebuilds it byte for byte from the tag: it archives v4.3.3 into a temp
dir outside the repo, boots that runner, seeds, pins every timestamp,
VACUUMs and dumps.
The suite runs the shared upgrade assertions and checks that every row
of those tables comes through with its values, that the boot copied the
database first and the copy still is the 4.3.3 schema, and that the
reminder backfill marks only the reminders whose day has passed.
Migrations only go forward, and since #2610 an older image refuses a
database a newer one migrated. Going back after a bad update therefore
needs a copy from before it, and the only copies were backups the admin
takes by hand or auto-backup, which is off by default. The first boot
on 4.4 migrates every 4.3.x install through the legacy baseline and the
new migrations without either.
Before applying pending migrations to a file database that holds
anything, the boot now writes a VACUUM INTO copy next to it, named
pre-migrate-<schema>-<time>.db (legacy-<schema_version> for an install
the positional runner left, otherwise the last recorded migration), and
keeps the newest TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP (3). The copy is
written under a temporary name and renamed, so a crash never leaves a
truncated file that looks good. If it cannot be written the boot refuses
before anything is migrated; TREK_DB_PRE_MIGRATE_SNAPSHOT=false is the
documented way past that. The newer-database refusal names the copy
taken right before the newer release migrated past this one.
A fresh install, a restart with nothing pending, an in-memory database
and a restore's re-bootstrap (the archive is the copy) take none, and
NODE_ENV=test leaves it off unless a suite sets the switch.
A successful login starts tripSyncManager.syncAll() without waiting for
it. In authStore.push.test.ts that sync was real and could still be
logging when the worker shut down, which vitest reports as an unhandled
"Closing rpc while onUserConsoleLog was pending" and fails the coverage
job although every test passed. The spec is about push, so the sync is
stubbed.
Since the ORM migration the scan of every push to dev stops with "the
Node.js process running out of memory (heap size limit 4288 MB)" and
fails the job, while PR scans, which only analyse changed files, stay
under it. The hosted runner has 16 GB.
c6b6b83d3 wrote a real line break where `printf '%s\n'` belongs, so the
RUN instruction ended inside the quote and Docker read the next line as an
instruction of its own: "dockerfile parse error on line 135: unknown
instruction: '". Every image build from dev fails on it. PR CI builds no
image, and the scout job on the release PR was already red for another
reason, so nothing showed it.