The second import of the same export writes every column of the step row
as it already was (compared column by column), so the update is a no-op
and migration 0412 keeps updated_at and sync_version. The test expected the
version to move on every write; it now pins that it never goes backwards
and stays where the first import left it.
Six readers fitted a line by hand, four in the determinant form that
loses digits on a large x axis, and the two charts carried the same trend
function twice. linearFit / fitFromSums in lib/stats is now the one
implementation (centred sums, so a fit from points and one composed from
stored accumulators are the same arithmetic), trendLineByTime serves both
charts with the same two-decimal output, and a guard keeps hand-rolled
slopes out of the tree.
Five queryFns caught the ApiError and threw a plain Error with a
translated line, so the read-failure primitives always saw a generic
failure: an ended session offered Retry instead of Sign in, and a
refused share drew an error card. The queryFns now pass the error
through; the message lives on the primitive. The ECG list also says a
failed read instead of rendering nothing.
The guard now also reads every queryFn and refuses a catch that
re-throws a plain Error.
CREATE TRIGGER waits for an autovacuum that is preventing wraparound, and
every write to the table queues behind the waiting request, the running
release's included. SET LOCAL lock_timeout fails the migration after ten
seconds instead, and the file rolls back whole.
Migration 0412 starts autovacuum on the table at 5 % dead rows, and the
nightly check vacuumed at 10 %, a point autovacuum had always passed first.
Two triggers for one task only hid which of them did it. The nightly check
now measures and changes nothing: it warns when the dead rows reach four
times autovacuum's threshold, which means autovacuum is not keeping up, and
when the heap has outgrown its live rows. The operator's run keeps its
steps.
The Health Connect import stored its unit factors' binary noise as is
(grams to kilograms, millis to minutes, the day sums) and rounded the
pulse means and nutrients privately. Every measurement value now takes
importValue at the write boundary, and importValue takes a precision for
the two places that need another one. The rounding guard now covers the
import directory and the scaled Math.round form.
WHOOP and Oura answer the last page with an empty next_token as well as
with null. The walk followed the empty string as a cursor, the client sent
no token, read the first page a second time and the walk ended as a repeat,
so the records came in twice and the backfill stayed incomplete for good.
An empty cursor now ends the walk, for every provider that walks through it.
One run reads at most forty pages of a hundred activities. A longer history
ended as incomplete with no note of how far it got, so every boot read the
same newest pages again, spent up to 190 of the instance's 200 requests per
fifteen minutes on them, and never reached the older activities.
The run now records the start of the oldest activity it read and sends the
next run for after the rate window; that run asks Strava for the activities
before it. A run that gets no further records nothing and sends nothing. The
place is cleared when the backfill completes.
Notes, workout tracks and the cached insights payload each read their
ciphertext and legacy column their own way: the cache read the readable
column first, and the two that read the ciphertext first disagreed on a
value that does not open. readSealedOrLegacy fixes the order (ciphertext
whenever present, the readable column only without one, never as a stand-in
for a failed open) and makes each caller state what a failed open means.
A day's sleep is the main night plus its naps (daySleepMinutes). The
series, the sleep score and the composition already read it that way; the
sleep list, the CSV export and the correlation sleep channel read the main
night alone, so a day of naps listed as 0 h and an average over the list
counted it as a night without sleep. The list now carries the day total in
value and the main night alone in the new mainAsleepMinutes field; the nap
caption reads as included.
GET /api/sleep/night without a date answered the latest day holding any
sleep, so a nap after the last night left the hypnogram with no night to
draw. It now walks back to the latest night with a main sleep; nap days
stay reachable by date.
The SQL night page bounded a night by its asleep sessions only, so an
in-bed-only session of the same night fell outside a chunked read that the
one-night read counted. The span now covers every session of the night.
pg-boss counts a job's expiry from the fetch and ends the handler at it, so
a job that waited for the heavy lane spent its expiry in line: a long import
let the weekly backup expire through every retry, and an upload that waited
past its own expiry was failed, stopped counting as in use and lost its
staged file to the sweep. A job that finds the lane taken now returns to
created with start_after a minute ahead, at the same attempt, in one
statement, and runs when the lane is free.
The full-history integration drain joins the lane, and the guard checks that
every heavy queue's policy lets a job go back to created.
Excluding chronic episodes from the zero-fill also dropped them from the
read, so their day-logs never reached the series and an account with only
a chronic condition had no symptom channel at all. The read now takes
every in-window episode; only the ones that can trigger Rest Mode carry a
zero-filled span, and every logged day counts.
Four per-metric status snapshots read mood context without the tombstone
filter, and the blood-pressure snapshot read measurements the same way, so
a deleted entry kept shaping the text written about it. The breathing
screen and the HRV widget probe had the same gap.
One reader now serves the shared mood context, and a guard derives the
soft-deleting models from the schema and fails on any unfiltered read in
the analysis, insight and model-input directories.
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
Comments used to open with the identifier of the work item that produced
them. The identifiers point into notes that never ship, so a reader meets
something that looks like a reference and leads nowhere.
eslint-plugins/healthlog/planning-tags.js defines the shape once: a head
of tag codes and planning words before an em dash, optionally after a
release version. The version and any issue reference stay; the tag goes.
A line without a version must carry a code that is neither domain
vocabulary (B12, PM2.5, UTF-8) nor used in the file's code.
scripts/strip-planning-tags.mjs locates comments with the TypeScript
parser, rewrites only their text, and refuses any file whose
comment-free printout changes. The healthlog/no-planning-tags rule uses
the same matcher, with an autofix; it is enabled together with the sweep.
react-hook-form, @hookform/resolvers, next-themes and @vitejs/plugin-react
have no import anywhere in src/, scripts/, tests/, e2e/ or the build and
test configs; knip only stayed quiet because each sat on its ignore list.
Forms keep a controlled draft and validate with the shared Zod schemas,
and the theme bootstrap is hand-written. The ignore entries, the comments
that still named the form library, and the guideline text go with them.
The setup file runs once per test file and imported all eight message
catalogs, several hundred kilobytes each, which made setup the largest
share of a component-suite run. It now primes EN, the fallback floor of
the t() chain. A test that renders another locale primes it at module
scope through primeLocales(), which loads the catalog via the production
loader in a file-level beforeAll.
The layout write resolves the caller, so its refusal is always the
caller's own and auditLog never files it; the call, its dedup and its
tests go, and the refusal stays on the wide event. The series route
tests now pin the breadcrumb call instead of a ledger row the caller's
own request no longer writes.
users.insights_cached_text held the model's briefing, readings and
recommendations about the account in the clear. Migration 0411 adds
insights_cached_text_encrypted (AES-256-GCM, binary layout, purpose-bound
label) and a partial discovery index; the free-text encryption backfill
seals the existing rows one at a time, fail-closed, carrying updatedAt and
guarded on the value it read. Every reader, writer and clear goes through
cached-text-cipher.ts, and a guard keeps the column names there. The
column joins the encrypted-column registry and the key-rotation script,
stays out of backups as operational, and is reset by the data wipe.
The dashboard layout route wrote its validation breadcrumb with a bare
auditLog.create, so it skipped the delegated-only rule and kept filing
the caller's own refusals.
A list that stops at its own take and says nothing lets the last row read
as the oldest there is. Capped lists now fetch one row past the limit and
answer meta.truncated and meta.limit on the success envelope, so data keeps
its shape. Applied to the capped lists without a total count: rhythm
events, ECG recordings, sharing grants and activity, illness episodes, side
effects, personal records, security activity, cycles, family history,
allergies and mental-health assessments. The activity feed no longer
reports a list that is exactly full as cut.
The page-walk guard flagged the backfill progress codec's page > 0 check, a
validity test rather than a ceiling, and missed the list walk's own loop
(pageCount - skipped < maxPages). The matcher now reads a counter offset and
ignores a literal bound of 0 or 1. Strava and the rollup chunks walk through
walkPages, so their pending entries go. The dataPoints.list walk stays outside
as a named exception with a match allowance: it is bounded by progress rather
than by a cursor ceiling, streams pages, stops on a slice budget and keeps its
place, and its backfill decides with a sliced verdict.
Each rollup chunk kept its own token loop with a hundred-page bound. It now
walks through walkPages: a token handed out twice ends the chunk at once
instead of re-reading the same page up to the bound, and a chunk cut off is
reported on the wide event as well as through the client outcome.
The activity loop stopped at its page ceiling and returned what it had read,
and the backfill stamped the account complete over it: a history longer than
forty pages was cut off for good. The walk now runs through walkPages, which
reports a walk cut off at its ceiling, and the backfill reads backfillVerdict
like the WHOOP and Fitbit backfills: a truncated walk leaves the marker unset
without a retry, and a connection waiting for re-authorisation returns
without walking.
The Coach polled a staged document every 2.5 s until its content index
existed, which for a format that is never indexed meant for as long as the
Coach stayed open. It now stops with the same upload processing window the
documents list uses. Every client poll, its interval and its stop
condition is listed beside the dashboard cadence constant.
The side-effect taxonomy imported its enums from the Prisma client entry,
and the recurrence engine the medication wizard previews schedules with
imported the logging context, which brings node:async_hooks, node:crypto
and node:os along. The taxonomy reads the enums entry now, and a rule that
does not parse is reported through an observer the server registers, the
pattern the source-priority parser already uses. A guard walks the import
graph from every client component and fails on a server-only module.
A *.validation-failed row was written on every malformed request, the
caller's own included, and the PR detection enqueue wrote one per batch.
Neither is a security event and nothing reads them. auditLog now files a
refused attempt only when a delegate made it, for the owner's activity
feed; otherwise it rides the wide event as meta.rejected_attempt. The PR
detection enqueue moves to annotate().
The mapper kept its own parseFloat(n.toFixed(2)), which rounds a half at
the third decimal down whenever the double sits just below it: a weight of
72 345 g stored as 72.34 kg, a distance of 1 000 005 mm as 1000 m. The
shared importValue rounds on the decimal the value prints as. The rounding
guard drops its last pending entry and also catches a round2 declared as a
const.
Correlation fixtures wrote minute-long midday sleeps, which the nap
rule now reads as naps; they write nights. The analytics test mocks the
user read the empty recovery reason makes, and the control-byte guard
asserts the size of its sweep.
http.route held the concrete pathname, so every record id minted a route of
its own and grouping by route answered nothing. apiHandler recovers the
template from the handler's params and puts it there; http.path keeps the
concrete path, and path-secret redaction still runs on both.
Roughly half of the refusals answered prose alone, so a client could branch
on the code for one 404 and had to fall back to the status for the next.
apiHandler now completes every error envelope with the status's generic
http.* code when the route named none; a route's own code still wins. The
family joins the published catalogue, and the profile form only shows a
code's sentence when the code has one.
The cookie resolvers folded a failed query into null, so a database restart
answered 401 and every client discarded a good credential. The lookup now
raises SessionStoreUnavailableError, which reaches the wire as 503 with
auth.session.unavailable. Logout still clears the cookie on a failed lookup
and records a warning.
The cascade behind user.delete removes every row the account owns, so on a
large record it outlived Prisma's 5 s interactive default and rolled back
with a 500 on every attempt. The deletion stays atomic with the Guardian
check and now carries the same budget as the data wipe.
`updated_at` sits in the sync-feed index, so every update that moved it
wrote a new row version on another page and an entry in every index.
Provider re-syncs rewrite many unchanged rows, which grew the table to
several times its live size and sent each unchanged row down the sync
feed again. Migration 0412 adds a row trigger that keeps `updated_at` and
`sync_version` when an update changes nothing else, so such an update is
heap-only and the feed stays silent; it sets `fillfactor = 85` and a 5 %
autovacuum threshold. The maintenance queue gains a nightly check that
vacuums when dead rows pass a tenth of the live ones and warns when the
heap is more than three times what its live rows need, which only a
`VACUUM FULL` in a window gives back. The admin card keeps showing the
operator's runs only.
The WHOOP, Oura and Withings list walks and the Fitbit exercise walk each
kept a page loop that, at its ceiling or on a cursor that did not advance,
returned what it had read and said nothing, and the backfill around it
stamped the history complete. `walkPages` keeps a ceiling but reports a
walk that stops with a page pending, or on a cursor it already followed,
as truncated. A backfill reads that through one verdict: a failure
retries, a truncated collection leaves the marker unset without a retry,
a clean run stamps it. The Fitbit exercise walk may read a hundred pages
during a backfill instead of twelve. A guard keeps page-counting loops out
of the provider directories, with Google Health and Strava listed until
their next change.