The unit fix only helps the next import. An account that imported an
archive before it carries a decade of days understated 1000x, and the
authoritative repair — re-importing the archive on the fixed build —
needs an archive the account may no longer have.
`scripts/repair-apple-health-distance.ts` is dry-run by default and
selects rows only by the stamp the archive fold itself writes: the
EXPORT_XML_SOURCE_MAX provenance plus the `stats:` external id minted
for that fold. Never by "the value looks small", so a row a native sync
has since overwritten is left alone. `--apply` multiplies one account
per transaction and writes the audit row in the same transaction, which
is what makes a second run a no-op. The archive's own unit is named by
the operator (`--unit=mi`) because the discarded attribute is the defect
itself, and a row whose repaired value would leave the plausible range
is reported rather than written.
Refs #944
Apple stamps every quantity `<Record>` of an export archive with the
account's own display unit, not the unit the mapping table assumes: a
metric archive reports walking distance in km, an imperial one in mi,
body mass in lb, energy in kJ, temperature in degF. The mapper accepted
`input.unit` and never read it, so a 2.484 km day was folded into the
day's total as 2.484 m and a decade of imported history read as a couple
of metres walked.
`hk-units.ts` is now the one place a HealthKit unit is converted, shared
by the record path and the workout path (which carried its own km/mi
branch — the duplicate is what let the record path stay wrong). It
converts only inside a family and only for units Apple can emit, and
returns the reading untouched for anything it cannot place, so an
archive that already speaks the table's unit is unchanged.
Every mapping-table entry whose unit cannot be converted — a
dimensionless count, a pinned event, a logarithmic dB level, a compound
rate, the percent units whose value rides as a 0..1 fraction — now
carries a written reason, and a structural guard fails on an entry that
offers neither a convertible unit nor a reason.
Refs #944
The Apple export writes DistanceWalkingRunning in the account's own
length unit; the record mapping assumes metres and never reads the
attribute, so a 2.484 km day lands as 2.484 m.
Refs #944
Every accountAccess entry on GET /api/auth/me carries writableDomains
and manageableDomains: the sections the grant opens, intersected with
the sections whose routes accept a delegated write at that level. A
READ grant gets two empty lists, a WRITE grant gets writable sections
only, a MANAGE grant gets both, and the vault appears in neither.
The client parser checks the structural invariants (no repeats, within
the grant's sections, manageable within writable, nothing below the
level) and requires the two published views of the active record to
agree on the lists. Regenerate the OpenAPI document and extend the
payload consumer guard with the active-record readers.
Add the one table of which record sections have a route declaring
requireRecordAuth("write") or ("manage"), derived from the route files,
and a structural guard that re-derives it from src/app/api on every run
and fails on a disagreement in either direction. The matcher tolerates
the line break a formatter puts after the opening parenthesis and
asserts a non-zero match count.
delegatedDomains() turns a grant's level and scope into the sections it
can add to and the sections it can change, keeping write within manage.
"Sign out everywhere" revoked every other refresh token and nothing else.
A native login is two rows, and the Bearer check consults only the
paired access token, so the other phones kept working until that token
expired, up to a day on the default policy. The route also handed
destroyOtherSessions a session-kind credential for a Bearer caller, whose
session.id is the ApiToken row id; the exception matched nothing and the
calling phone revoked its own refresh token. destroyOtherSessions now
finds the paired access tokens through the refresh rows and revokes them
inside the same transaction, reporting the count, and the route names a
Bearer caller by its access-token hash. Programmatic tokens are untouched.
rotateRefreshToken marked the consumed row under usedAt: null alone, so a
revocation landing between the read and the write was overtaken and a
pair minted after the family ended stayed live. The guard now includes
revokedAt: null; on a lost race the minted pair is retired and the reason
distinguishes a revoked family from a double consume.
withIdempotency consulted the grant only at replay time, and only for its
existence; a narrow-scoped token was checked only on the delegated arm.
Both move into the cell key: a delegated cell carries the grant's id,
level and scope fingerprint, a narrow token's cells carry the token, and
a request with no live grant neither reads nor claims a cell. Cookie and
wildcard own-record cells are keyed byte-for-byte as before.
Tests: integration for the paired access tokens (cookie and Bearer
caller), a deterministic revoke-mid-rotation race against Postgres, unit
coverage for the new cell keys, and the structural guard moved to the
new invariant.
Rotation revoked the access token paired with the consumed refresh token
the instant the rotation committed. A native client with requests already
on the wire got 401 revoked on those, read that as "re-authenticate",
rotated again with the refresh token it had just spent, and tripped reuse
detection: the device's whole token family died because the client was
busy at the boundary.
Pull the paired token's expiry in to fifteen seconds instead. It is long
enough for an in-flight request on a slow mobile link to finish, including
a retry, and short enough that the token gains nothing meaningful over the
validity it already had. bearer.ts answers a passed expiry with reason
expired, which a client treats as "refresh", not as "sign in again".
LEAST(expires_at, $1) takes the minimum inside the single UPDATE, so the
write can only ever shorten: a token already inside its last seconds keeps
its own expiry, and there is no read-then-write window. The bound value is
an ISO-8601 UTC string cast in SQL so the comparison does not shift with
the host's timezone.
Reuse detection is untouched. A replayed refresh token, or one presented
under a spoofed device id, still revokes the family and its access tokens
on the spot, and so does the logout path.
Nothing calls it any more: the weekly pass and the off-host job both
walk the payload through the streaming writer. Keeping an exported
function that builds the entire record as one string invites the next
caller to reintroduce the memory profile this branch removes.
The off-host job was said to benefit from the streaming work the weekly
in-database backup got, and it did not. Only the payload builder was
shared: `runOffhostBackup` called `buildFullBackupJson` for the whole
document, `gzipSync` over that whole string, a whole-buffer
`createCipheriv` pass over the result, and handed the finished buffer to
a single `PutObject`. Four full copies of the record alive at once, on
the same arithmetic the weekly pass had already been fixed for.
Configuring an S3 target on a long-lived record therefore restarted the
container. On an account of 445 000 measurements the JSON alone is
242 MB, and against a 1 GB container's 524 MB heap the run died of heap
exhaustion seventeen seconds in — taking every signed-in session with
it, because the job shares the app process.
The write path is a stream end to end now: `streamFullBackupJson`
produces the document a page at a time, gzip and an incremental
AES-256-GCM writer consume it as it arrives, and `@aws-sdk/lib-storage`
puts it up in two 8 MB parts. What the process holds is fixed by the
pipeline's shape rather than by the record. Measured on that same
445 000-measurement account under `--max-old-space-size=450`, with the
heap pre-loaded to what a warm server holds: the old path dies with
`Ineffective mark-compacts near heap limit`, the new one finishes with a
peak 79 MB above its baseline and writes a 9.1 MB object.
The stored envelope gains version 3, which moves the GCM tag from in
front of the ciphertext to the end. That is the only change the stream
required — the tag exists only once the last block is in, so a leading
one means the whole object must exist before its first byte can be sent.
Nothing about the authentication moves: the tag still covers every
ciphertext byte and is verified before a byte of plaintext comes back.
Versions 1 and 2 still read, so every object already in a bucket
restores through the same reader with no flag to tell them apart.
The incremental writer is the crypto module's, split into its byte-level
half so the base64 form the in-database blob uses and the raw form an
object needs are one implementation under two framings. The off-host key
handling is unchanged: a separate `BACKUP_ENCRYPTION_KEY`, 64 hex or
32-byte base64, passed in rather than looked up.
A run that uploaded nothing for anybody is a failed job now, not
`ok: true` with an empty bucket behind it. Wrong credentials, a missing
bucket and an unreachable endpoint all fail every account rather than
one, and the target's own sentence rides out as the cause. A run where
some account got a copy still succeeds; retrying the whole cohort over
one object would re-upload everybody's.
The one ceiling left is structural rather than a memory bound, and it is
counted rather than discovered: a multipart upload carries 10 000 parts,
so an object past 80 GB is refused for that account with a clear message
instead of failing halfway through with an SDK error about part numbers.
`tests/integration/offhost-backup-streaming-memory.test.ts` pins the
budget from both sides — what the streaming uploader holds, and that the
materialising path does not fit the same budget on the same fixture in
the same process, so the number cannot pass by measuring nothing.
A gateway can answer a failed generation with HTTP 200 and put the reason
in the body: a top-level `error` object and no `choices`, or
`choices[0].error` beside `finish_reason: "error"` when a generation dies
mid-flight. Both fell through to the empty-content throw, so a dead credit
balance, a data-policy block, a rate limit and a model that is down all
recorded the same hop: status 0, reason "returned empty content", no
excerpt. Nothing on the provider health card told them apart.
Raise the shape a non-2xx reply raises instead, with the gateway's own
message leading and its `error.code` as the status when that code is an
HTTP error status. The chain classifier, the health ledger and the card
then see 402 / 403 / 429 / 503 and treat each of them the way they treat
the same status on the wire. Message and excerpt are bounded and
key-redacted by the same helper the non-2xx path uses, so remote text is
no more trusted here than it is there.
Gated on the gateway tag: api.openai.com signals every failure with a
status, so the pinned tags parse exactly as before, and a genuine empty
reply stays the empty-content throw it was. The local-endpoint provider
had the same hole on its buffered path, its buffered-reply fallback and
its stream, and gets the same treatment. The body classifier learned the
two account-side refusals a gateway words in its own vocabulary rather
than the vendor's.
The per-user date order reached the entry form and nothing else. A
reporter switched away from MM/DD/YYYY, the date picker followed, and
the dashboard chart axes and the measurements list kept the old order.
`makeFormatters` took `timeFormat` and `dateFormat` as optional
parameters defaulting to "AUTO", so six of the seven constructions in
the tree silently rendered the locale default and nothing anywhere
said the setting had been dropped. Drop both defaults. The compiler
then names every caller: the chart bucket labels, the legacy
`@/lib/format` helpers the measurements list renders through, the
medication compliance and efficacy charts, the doctor-report PDF and
the clinician share view.
Thread the preference:
- `makeBucketLabelFormatters` takes the order; the health and mood
charts read `useDateFormatPreference()`. The UTC pin decides which
day the label names, the preference decides how it is spelled, and
the two are separate questions.
- `format.ts` reads `readStoredDateFormat()` beside the timezone and
hour-cycle mirrors it already read.
- The PDF takes `dateFormat` as a required render option, alongside
`timeFormat`, which was optional for the same reason.
- The clinician share view spells the OWNER's dates the owner's way.
It also picked up the owner's hour cycle, which it had been ignoring
while the PDF route beside it already honoured it. Both resolve
through the new `resolveUserFormatPreferences`, the counterpart to
`resolveUserTimezone`.
Textual months stay on the UI locale. The preference pins numeric
field order by rendering through de-DE / en-US / en-CA, and a textual
month through those would change the month name's language.
Add `date-order-explicit-formatter-guard`: a raw numeric date renderer
must take its locale from the preference or carry a written reason,
and a formatter construction may not hard-code "AUTO" for the order.
Its allowlist holds thirteen calendar-day key builders, each with the
reason it has no order to get wrong. The sibling timezone guard passes
every one of them, because naming a calendar says nothing about
spelling a date.
Refs #922
crypto.randomUUID() is [SecureContext] in the Web Cryptography IDL, so a
page served over plain HTTP never gets it. Measured in Chromium against
http://<lan-ip>: isSecureContext false, randomUUID and subtle undefined,
getRandomValues still a function.
The document vault minted its queue id and Idempotency-Key that way, inside
the file-input change handler. On a LAN self-host the call threw before a
queue row existed, so picking a file produced no upload, no error and no
trace — the picker simply looked inert, and the only way through was to
reach the app over HTTPS. The client transport has minted a default
Idempotency-Key the same way since v1.37.18, which put every apiPost and
apiPatch on the same footing: on such a host nothing wrote at all.
Add randomId(), which prefers the platform call and otherwise assembles a
v4 UUID from getRandomValues — the same CSPRNG, the same 122 bits, no
secure context required — and route every browser-side call site through
it. Nothing in an upload needs one: the bytes travel over multipart XHR and
every cryptographic operation happens on the server. Passkeys, web push and
home-screen install still need HTTPS; browsers withhold those outright.
A structural guard fails a direct crypto.randomUUID() anywhere outside the
two server-side files that may keep it, so the class cannot return one call
site at a time.
Also point the CSV and JSON import cards straight at the import guide. They
resolved through a redirect under the integrations tree, which is the wrong
place for a guide that is not an integration runbook. The suite that
covered those links pinned the old spelling rather than the guarantee; it
now asserts an absolute docs-site URL, no internal path, and new-tab
hygiene.
The mint's rate limit bounds a burst and does nothing about accumulation:
a stuck automation retrying politely inside its 10-per-minute allowance
still leaves several hundred live credentials behind in an afternoon,
each one to be revoked by hand.
Count live rows before the create and refuse at ten.
Live, not minted-ever, so revoking frees a slot and an expired token
stops occupying one — the ceiling bounds what currently exists rather
than what the account has ever done. Scoped to measurements:write for a
reason worth stating: signing in mints a wildcard access token per
session and those accumulate on their own, so counting every row would
let ordinary browser use exhaust the budget and then refuse a mint for a
reason invisible to the person hitting it.
409 rather than 429. This is a conflict with the account's current
state, not a rate: it does not clear by waiting, it clears by revoking,
and a 429 would tell the caller the opposite of what is true.
Placed after validation, so a malformed request hears about the
malformation rather than the ceiling, and before the create, so hitting
the limit costs no credential.
Ten is set to catch a loop rather than to ration — a household running
Home Assistant, a scale bridge and a couple of scripts sits at three or
four, and it is not a security boundary since the holder can revoke and
mint freely.
Tests pin the boundary, the query shape and the ordering, and were
mutation-checked: turning >= into > fails the ceiling case, and dropping
the scope filter fails the query-shape case. The integration leg seeds
a wildcard and an expired row as decoys, then revokes one live token and
asserts exactly one slot opens — the leg that fails if the count is of
mints rather than of tokens alive.
Only CHANGELOG.md conflicted: v1.38.7 released while this branch was open,
so the new release heading landed where the branch's [Unreleased] section
sits. The HRV entries stay under [Unreleased] above 1.38.7; everything from
1.38.7 down is upstream's file verbatim.
docs/api/openapi.yaml auto-merged. Upstream's only change there was the
`info.version` bump to 1.38.7, which the merge took while keeping this
branch's DashboardLayoutReadResult, and package.json now reads 1.38.7 to
match — so the merged file should equal a fresh generate. It is a generated
file, though, so confirm with `pnpm openapi:generate` rather than trusting
the three-way merge.
The widgets GET grew a response field and the spec was never told. Nothing
would have caught it: `openapi:check` compares the generated yaml against
the Zod registry, and an undeclared field changes neither — so CI stays
green while the published contract quietly stops describing what the route
returns. iOS decodes this payload.
`dashboardLayoutResult` is shared by the GET and the PUT echo, but the
field is resolved per request from the caller's modules and their data, so
it exists only on the read. A separate `dashboardLayoutReadResult` extends
the shared schema for the GET rather than widening both verbs and
describing a field the PUT never sends.
docs/api/openapi.yaml is NOT regenerated in this commit: `pnpm
openapi:generate` cannot run here (the Prisma engine download is refused,
so `@/generated/prisma/client` is missing and the registry will not load).
The tree is therefore in drift on purpose and `pnpm openapi:check` will
fail until `pnpm openapi:generate` is run and the yaml committed.
The writer added in v1.38.6 compared the process's live heap usage against
80 % of V8's heap limit and aborted above it. That reading is not the
backup's footprint; it is the whole process's, garbage included. A
long-running app sits at 400 MB of largely collectable heap, so on a 1 GB
container (a 419 MB budget) the weekly pass aborted the first flush of every
account: four out of four in seven seconds, the smallest of them a demo
record whose entire stored copy is 1.2 MB, each told its record was too large
and its operator to buy memory.
Drop the gauge. What the writer holds is bounded by its shape and needs no
reading to prove: the three unbounded tables are pulled a page at a time,
every other section is released as soon as its JSON is in the sink, and at
most one flush of text is pending. The one copy that does grow with the
record is the stored blob, so the bound moves there — counted in the
ciphertext bytes actually produced, against a fifth of this process's heap
limit, which is what two copies of the answer plus a live set have to fit
inside. The message names both numbers.
A pass that wrote nothing for anybody now fails its job instead of reporting
`ok: true` with `backed: 0`, so the failing-queue panel and the backups page
read the same thing an operator would. A pass where some accounts were
written keeps passing with the failures as counts.
Measured under --max-old-space-size=450 from a heap deliberately dirtied
first: 445 000 measurements peak at 28 MB held by the backup, a one-row
record at under 1 MB.
Five points from the review, in the order they were raised.
1. The RMSSD marker did not survive. The tile label paints truncated,
uppercase and on one line, so an appended `· RMSSD` was the first thing
dropped in the longer locales — the part a reader needs to tell a 40 ms
RMSSD night from a collapsed SDNN one. `measurements.typeHrvRmssd` already
exists in all seven bundles, so each series now carries its own key.
`pickHrvSummary` returns that key beside the series it chose, which keeps
one mapping from series to label rather than a conditional at the call site.
2. The module gate bit on one path only. `gateSummariesByModules` was
private to the snapshot builder, and with the snapshot flag off the tiles
are fed by `/api/analytics`, which filtered nothing — so recovery-off plus
RMSSD-only still painted the tile, on the exact path the helper's docblock
promised it could not. The filter moves next to the map it reads, and both
analytics slices apply it. Gated after the cache read, so the cached cell
stays module-independent and toggling a module takes effect on the next
request instead of waiting out the TTL.
3. The same class one step on: recovery off plus RMSSD only left a switch
that was on, a tile that could never paint, and no explanation. The repo
already calls that a dead switch and hides the row; HRV is only dead
sometimes, so the answer needs one fact the Settings screen does not have.
The server resolves it — `unavailableWidgetIds` on the widgets payload —
and Settings drops those rows. An account with SDNN keeps the toggle: its
tile still works with recovery off, because SDNN is a vital no module owns.
The SDNN probe is an index-only existence check that runs only when
recovery is off, outside the layout cache so the row goes the moment the
module does.
4. The brittle test sliced from the helper to the end of the file, so any
function added below it would have gone red pointing at HRV. Bounded to the
function's own body, with a guard on the bound itself. The three wiring
assertions matched formatted source text — they would have passed just as
well if the tile never rendered — and are replaced by a test that renders
the tile through a real TrendCard and asserts the reading is on screen.
One negative source check is kept, naming the expression the bug lived on
rather than the formatting of its replacement.
5. Rebased onto v1.38.6; the changelog entry sits under [Unreleased].
Not verified here: `pnpm db:generate` cannot run in this container (the
Prisma engine download is refused), so snapshot.test.ts, the widgets route
test and the analytics route test could not load, and typecheck is
incomplete. Lint, format and the HRV suite (17 tests) pass.
HRV lives under two measurement types and they are not the same measure:
Apple Health / Fitbit / Google Health write SDNN as
`HEART_RATE_VARIABILITY`, while Oura / Polar / WHOOP — and a bridge
pushing a watch that reports it that way — write nightly RMSSD as
`HRV_RMSSD`. Every HRV surface unions the two: `/insights/hrv` through
`fallbackMeasurementType`, the Coach through
`COACH_SOURCE_MEASUREMENT_TYPES.hrv`, the MCP reads through
`withHrvFallback`. `sub-page-metric.ts` even says listing both is what
keeps "the tab, pill, and dashboard tile lit whichever source the user
has".
The dashboard tile did not. It read SDNN alone, so an RMSSD-only account
got a Settings → Dashboard toggle that turned on and rendered nothing,
with no explanation, while the insights page charted the same readings —
which is why it reads as a broken layout rather than a missing series.
`pickHrvSummary` picks the series with the same rule `withHrvFallback`
uses: the primary wins whenever it has any rows, the fallback only when it
has none. Never a merge — the two share `ms` but measure different things,
so blending them would invent a number no device reported. The caller gets
the winning type back, because the freshness caption has to read the
series on show and the label has to name the measure when it is not SDNN
(`· RMSSD`, as the insights chart title already does).
Second, less visible half: `HRV_RMSSD` is recovery-owned in
`METRIC_STATUS_MODULE_OWNERS`, but the dashboard snapshot gates off
`SUMMARY_TYPE_MODULE`, a different map, and that one did not list it. The
omission was inert while the tile read SDNN alone; adding the fallback
without it would hand a recovery-off account a tile built from
recovery-owned rows — the exact hazard the owner map documents. The entry
goes in beside the four sibling recovery types, so the gate stays server
side and the client keeps no competing copy.
`DataBackup.data` holds every weekly disaster-recovery snapshot and every
uploaded pack, AES-256-GCM at rest like everything else, under a column
called `data`. The rotation registry was keyed on the `*Encrypted` naming
convention, so the script never read a backup, reported zero rows
remaining, and the runbook read that zero as permission to retire the
previous key. Following the documented procedure destroyed every stored
backup — the copy an operator reaches for precisely when nothing else
works.
Register it, and register the idempotent-replay response cache the same
sweep turned up. The backup column walks in bounded batches (one row is a
whole compressed account) and re-seals the ciphertext without reading the
plaintext, so both stored envelopes come back byte-identical and so will
any later one. The cache is marked disposable: a row it cannot read is
dropped rather than counted as an error, because keeping it only
guarantees the next replay gets a body it cannot parse.
Running the script against a seeded database rather than reading it turned
up two more. It died on the mood day-context note, whose table is keyed on
the entry rather than on an `id`, and abandoned every column after it —
the backups included. And the document index's verbatim text rotated
without ever being named in the summary.
Make the naming assumption stop carrying weight. A new guard traces the
encryption helpers through their wrappers, walks every Prisma write
payload, and fails when a column receives ciphertext without being
registered. It asserts an anchored, non-zero match count so a matcher that
stops matching fails instead of passing. A second check pins each model's
primary key against the schema.
The script now ends by naming the registered columns it did NOT walk and
exiting non-zero when any is missing: a zero that means "nothing left" and
a zero that means "never looked" must not print the same. The runbook's
retire-the-key step was rewritten around that signal, and tells an
operator who already rotated on an older release how to get their backups
back.
Three assertions repeated 18.016 and the product it gave. They were the
reason the two factors could sit in the tree at once looking correct. They
take the exported constant now, and the parser test asserts the round trip
a reader on mmol/L experiences rather than a magic product.
`User.glucoseUnit` has existed since v1.2 and around thirty surfaces read
it — the dashboard tiles, the glucose page, the targets panel, the CSV and
FHIR exports, the doctor report, the Coach snapshot, the low-reading push
copy. Nothing could ever write it. No route, no schema, no field on any
form, so every account resolved to the mg/dL default and the mmol/L half
of the app was unreachable code. Anyone who reads glucose in mmol/L
converted in their head on every screen.
This is the missing end: a `GET`/`PATCH /api/auth/me/glucose-unit` scalar
on the pattern the unit-preference endpoint next door established, and a
dropdown in the profile form beside it. Deliberately its own control
rather than a branch of metric/imperial: metric countries are split on
which unit they read glucose in, so one dropdown would get half of them
wrong.
Storage is untouched and stays untouched. Every reading is written in
mg/dL by every ingest path and the unit only selects a display branch, so
nothing already recorded is reinterpreted. The entry form moved with the
preference, which is the half that could have hurt: it asks in the chosen
unit and inverts through `toCanonicalMgdl` on submit, so a 5.3 typed by an
mmol/L reader is filed as 95 mg/dL rather than as 5.3 — a value the
plausibility band accepts in silence and every surface reads back as a
severe hypo.
The contract gains the endpoint and nothing else; the account payload
already carried the field.
A value imported in mmol/L was multiplied by 18.016; one displayed in
mmol/L was divided by 18.0182. The two sat in different files, both
correct-looking, and a reading that went in as 5.3 did not come back out
as 5.3. Latent while nobody could select mmol/L, live the moment they can.
One exported constant now, on the guideline figure the display path
already used, and the glucose insight page takes it from there rather than
keeping a third copy. The test names the constant instead of repeating the
digits, which is how the two came apart in the first place, and it asserts
the round trip a reader on mmol/L actually experiences.
The import status endpoint returns the instant the Health app stamped on
the archive, the published contract requires the field, and the schema
comment said the parser filled it during unpack. It never did: ExportDate
sat on the parser's ignore list, so the column answered null on every job
ever run, for a year.
The parser reads the element now and hands the stamp to the worker on the
next chunk boundary, so the value lands while the run is still going and a
job that later fails still records which export it was working from. An
archive that omits the element, or writes a value the runtime cannot read,
leaves the column null rather than guessing at an instant.
No contract change: the endpoint and the OpenAPI description already
described this behaviour.
The compression that landed last release was real and was measured in the
wrong place. On a development machine with a multi-gigabyte heap the pass
finished; the container the job runs in is capped at 1 GB, which gives V8
a 524 MB limit, and there the same pass died of memory about fifteen
seconds in. Because the job shares the application process, that was not
one failed backup — it was a restart, and every signed-in session on the
host went with it.
Reproduced before touching anything, under `--max-old-space-size=450`
against a seeded account of 445 000 measurements, 30 000 mood entries and
60 000 intake events: `FATAL ERROR: Reached heap limit` at 34 s. The same
fixture doubled to 890 000 measurements now completes inside a 296 MB
limit, holding 16 MB of live heap where the old writer held 168 MB.
The document is written incrementally now. `buildFullBackupPayload` takes
a `deferBulk` mode that declares the three unbounded tables instead of
reading them, and `streamFullBackupJson` walks the payload in key order,
pulls those tables through page by page, and releases every other section
as soon as its JSON reaches the sink. `packBackupBlobStreaming` feeds that
into gzip and an AES-256-GCM stream, so nothing between the database rows
and the stored value ever exists whole. Both writers go through the same
per-row serialisers, and the round-trip suite pins their output byte for
byte rather than trusting that they agree.
The intake and mood reads are ordered totally now. A keyset cursor needs
it, and a partial order also let two exports of one account differ over a
tie, which every multi-dose day has.
The streamed ciphertext is a new shape at rest, because GCM only produces
its tag once the last block is in and a leading tag is what makes the old
layout unstreamable. It carries a marker no older writer can produce, so
the two families are disjoint by construction rather than by sniffing.
All three stored shapes — plain, compressed, streamed — restore, proved
both ways.
The writer also watches its own live heap and stops at 80 % of V8's
limit. An account too large for its host is now one failed backup,
counted in the run's meta, instead of a restart for everybody on it.
The stored column is the ceiling that is left: `data_backups.data` is a
single `text` value, so the finished artifact has to exist as one string,
and that is the only part of the job still growing with the record. The
operator runbook says so, with the number at which it starts to matter.
`User.locale` is the only signal a cookie-blind path has. The nightly
briefing warm, the Coach nudge, the reminder senders and the native aggregate
all resolve from that column; the screen in front of the user resolves from
the `healthlog-locale` cookie first, then the column, then Accept-Language. So
a browser can read the whole app in German while the column says English, and
while it does, nothing on screen looks wrong: the divergence surfaces as one
generated paragraph in the wrong language inside an otherwise correct page.
Keeping the two in step was a fire-and-forget PUT from a mount effect. A 401
on the paint before sign-in, an offline blip, a tab closed mid-flight — each
loses the write, and the next mount repeats the same unchecked attempt rather
than noticing the column is still wrong, so a stale column can stand for the
life of the account.
`getSession` now corrects the column when the request carries a valid locale
cookie the column disagrees with. It is ordered by the request pipeline,
needs nothing from the client, and is self-limiting: once the two agree the
condition is false. Only a cookie counts, and a cookie is only ever written
alongside a committed UI locale, so Accept-Language never stamps a guess onto
a column that generated prose depends on. The mount PUT stays for the
server-set cookie it gets in reply, which Safari's 7-day cap on script-written
cookies makes worth having; it is no longer the mechanism.
The obvious suspect was a first-paint race between two unordered writes. It is
not that: the provider takes the locale the server already resolved and
nothing on the mount path changes it, so the effect fires once with the right
value. `locale-first-paint-single-write` pins both properties so a future
mount-time flip has to face them.
Three pieces of code answered "what hour is it" for the salutation. The
snapshot builder computed a `greetingHour` in the user's zone and put it on
the payload so clients would not have to; no client ever read it. The
insights hero derived one through the shared `hourInTz`. The dashboard header
derived one through a local helper that fell back to the DEVICE clock
whenever the runtime could not resolve the configured zone, which is what
happens to an IANA entry the browser's data does not yet carry. At one
instant, for one account, those three returned 20, 21 and 05.
Keep the client end and drop the server one. The hour is a clock reading, not
a fact about the record, and the snapshot body serves stale for up to an hour
past its fresh TTL, so a stored hour can name a salutation the clock has left
behind. What the server IS authoritative for is the zone, and that already
travels on the same payload and is already what both surfaces read.
Both greetings now share `hourInTz`, so an unresolvable zone falls back to the
same default the rest of the app uses instead of to the device. The header's
private helper is gone; it had no other caller.
Two tests pinned the old shape by asserting the payload carried an hour.
Inverted rather than deleted: the payload must now be shown NOT to carry one,
which is the property that was missing when the field went unread for months.
`greetingHour` leaves the response contract, so a native client decoding it as
required needs the field made optional or dropped.
A `z.object()` without `.strict()` strips an unknown key and parses
clean, so a caller that misspells a field name gets a 200 and loses the
value with nothing to tell it. The worst of them was the Coach
question-dismiss endpoint, where an omitted `question` means "dismiss
all": a typo in that key wiped every pending question and answered as
though one had been dismissed.
Twenty-four inline schemas across twenty-two endpoints are strict now —
the settings toggles, the restore and bulk-delete endpoints, message
feedback, the admin user update, the onboarding acknowledgements, the
provider chain, the chart overlay preferences, the medication extractor
and the two client-side reporters.
The strip stays where it carries weight. Sync ingest, device
registration and the layout PUTs are built out of fields that shipped as
additive and optional, on the promise that a server which does not know
one ignores it; an app running ahead of the install it talks to is
routine here rather than exceptional, and refusing would turn a dropped
field into a refused batch — a year of history lost to one row, a
dashboard that will not save, a phone that quietly stops registering for
push. A read filter discards nothing when a stray query parameter rides
along, the OAuth endpoints are required by RFC 6749 and RFC 7591 to
ignore parameters they do not recognise, and an import file comes from
another version or another tool by definition. Each is registered with
its reason in a new inventory test, so a schema cannot join them without
someone making the call. That test parses the tree rather than grepping
it: fourteen of these schemas are written `z\n .object({…})` and a
`z.object(` sweep never saw them.
`sanitiseZodIssues` renders `unrecognized_keys` instead of passing it
through. Zod leaves `path` empty and puts the names in `issue.keys`, so
the sanitised issue named nothing at all and the caller was told only
that its body had failed. The names now ride a bounded `keys` field and
the message is rebuilt from them, capped at ten keys of 64 characters
and restricted to identifier characters, so a caller-supplied key cannot
echo back verbatim through the error path or into the audit ledger. The
names survive `stripValuesFromMessage` because a field name is
structure, not user-typed content.
The unit suite reported all 1937 files green and then failed the job with
`EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was
pending`, naming a different scoring test each time.
Vitest replaces globalThis.console with a sink that forwards every write
to the main thread as an onUserConsoleLog RPC call. When a worker
finishes a test file it awaits the calls in flight and then rejects
whatever was started after that point, so a console write from work that
outlived its test lands in the gap and fails the run. The blame goes to
the file being torn down rather than to the test that started the work,
which is why the named file never logged anything itself.
Three paths detach a promise and log on the handle: fireAndForget, the
stale-while-revalidate cache refresh, and the offline query persister.
They now register the handled handle in a set that both suite setups
drain in afterEach, so no test leaves work in flight. The registry is
inert outside test: nothing there awaits it, and holding a reference to
an unsettled promise would retain it for the life of a worker. No
production behaviour changes, so there is no changelog entry.
Evidence. A focused harness that starts failing background tasks and
returns hit the teardown error 10 runs out of 10 before the change and
0 out of 10 after. Instrumenting the real suite showed two detached
console writes per run escaping their test, both from the rollup tail;
after the change, none, and thirteen writes per run now complete inside
the test that started them.
The guard asserts the behaviour, not a count: a registered task must
keep settleBackgroundTasks from resolving ahead of it, and the set must
drain back to empty. It goes red on unregistered work, on a disabled
registry, on an unwrapped call site, on a sweep that matches nothing,
and on a setup that drops the hook.
The export was its only user, and the export now reads through a helper that
marks an undecryptable note instead of blanking it. Nothing else wanted the
silent null: the list DTO never carries the note at all, only `hasNote`, and
the single-resource GET has always used the throwing decrypt so a key gap
surfaces there.
Deleted rather than kept behind a dead-code exemption. A fail-soft twin
sitting unused next to a strict one is the exact shape this branch has been
clearing out — the next person writing an export reaches for whatever is
nearest, and the whole point was that the nearest thing should not silently
swallow a row. It had no test of its own, so none was removed; the three
comments that pointed at it are rewritten to say what is true now.
The weekly pass can stop and leave no trace where anyone looks. The
handler's own error never runs when the job is killed rather than thrown;
`recordError()` increments an unnamed counter; and the admin status page's
failing-queue panel looks back 72 hours, which for a weekly queue means a
permanent failure is on screen three days in seven. The backups page
itself showed an ordinary row with an ordinary timestamp, because a copy
made six weeks ago reads exactly like Sunday's until somebody subtracts
the dates.
The listing now carries two facts that fail at different times: the age of
the newest scheduled copy, which never leaves any retention window, and
the terminal state of the last scheduled run, which is the only place the
reason is written down. `readLastQueueRun` takes the newest run rather
than a window, so a weekly queue's outcome stays readable until the next
one replaces it, and it unwraps the value pg-boss stores a thrown error
under, so the page says "job timed out" instead of raw JSON. The
failing-queue reader gets the same unwrap.
Manual uploads are excluded from the cadence judgement by the shape of the
input, so an ad-hoc upload cannot make a dead cron look alive, and a fresh
instance is not called overdue for having no copy yet.
The listing also stops reading every blob into the web process to measure
it; `octet_length` answers that in the database, which on an instance with
large records was its own way of running out of memory.
The weekly pass built the whole account in memory, serialised it whole,
encrypted the string whole, and handed the result to the database whole.
That is four full copies of the record at the same moment — the object
graph, the JSON, the base64 ciphertext at 1.33x the JSON, and the copy the
driver makes of that parameter. Measured against a seeded account of
445 000 readings, the JSON alone is 242 MB and the run dies of heap
exhaustion; under a modest heap it never reaches the insert. The pass was
not slow, it could not finish.
A health record's JSON is extremely repetitive, so gzip takes that 242 MB
to 14 MB and every copy after it shrinks with it. The same run completes
in about twenty seconds with a peak heap of 434 MB and a stored row of
25 MB instead of 322 MB — which also matters because the copy lives inside
the database it would be needed to restore. Both backup jobs now hand the
serialised form back from a frame of its own, so the payload graph is
released before the compress step allocates anything.
The marker sits inside the ciphertext, so the stored column keeps the one
shape `decrypt()` already understands and rows written before this still
restore. The off-host object gets the same treatment through the version
byte its envelope always carried; version 1 objects still read back, which
both the restore drill and the restore script depend on.
The obvious saving — dropping soft-deleted readings, four rows in five on
a real record — is not taken, and the round-trip test now seeds a
tombstone and asserts it makes the journey. A tombstone inside the
retention window is what tells a synced client the row is gone and what
makes a re-sent sample a no-op; a restore without them brings back every
measurement the account ever deleted on the next device sync.
The `data-backup` queue was registered without send options, so every job
it minted took pg-boss's queue defaults: a fifteen-minute expiration and
two redeliveries. A snapshot that serialises every account's whole record
does not fit in fifteen minutes on a large one, so the pass was killed
part-way through, redelivered twice against the same record, and reached
`failed` with "job timed out" about three quarters of an hour after it
started — having never once completed.
Two hours, on the schedule rather than on the queue: `create_queue()` ends
in ON CONFLICT DO NOTHING, so a queue-level default would apply to a fresh
database and be inert on every instance that already has the queue. The
admin run-now route sends with the same options, or an ad-hoc snapshot
would silently keep the default the cron was rescued from.
The default two retries stay. The old redeliveries were pointless because
the first attempt could not succeed either; a transient failure genuinely
benefits from a second go, and the alternative is losing the week.
Six WEB_PUSH_* names and NEXT_PUBLIC_VAPID_PUBLIC_KEY sat behind the three
real VAPID_* variables in the env fallback. None was ever on the compose
environment: whitelist and none appeared in .env.example, .env.production.example
or the env manifest, so under the bundled stack a value set under any of them
could not reach the process. Remove them and the self-hosting note that
advertised them.
`adherenceRatePercent` returns null exactly when nothing was expected, so
callers render a placeholder instead of a misleading 0 or 100. The FHIR
exporter coalesced that null to 0, which would put a clinical claim of zero
adherence into an exported bundle. The guard above it means the branch
cannot be reached today; skipping rather than coalescing keeps it
unreachable if that guard ever moves.
A deleted reading is kept with a `deletedAt` stamp, and the schema says in
as many words that normal reads filter it. Three did not.
The custom-metric correlation channel filtered the metric's own tombstone
but not its entries', so a reading the person deleted kept shaping the
correlation they were shown. The status input hash — which decides whether a
cached AI assessment is still current — filtered its measurement arm but not
its mood aggregate and not its custom-metric count or newest-entry probe. A
deletion left that fingerprint unchanged, so the gate re-dated yesterday's
text and served it as today's over data that was gone.
Pinned in the existing soft-delete guard, whose matchers were checked by
removing each filter again.
A lab note or a biomarker context written under a key this instance no
longer holds came out of a portable export as `null` — byte-identical to a
column that was never written. The restore importer then wrote nothing, so
the text was gone and nothing said it had ever been there. The whole loss
fit inside a fail-soft `catch` written for a list view, where returning null
is the right answer.
Both now export a short marker naming what happened, and the marker survives
a restore. The Coach transcript already did this with a placeholder sentence
and the doctor report with a rendered flag; the marker is now one constant
the transcript shares, and its comment says why an empty string will not do.
The unit test that required `note: null` here pinned the loss. It keeps the
half that matters — one bad row must never abort the export — and asserts
the marker instead. The round trip is proven end to end against a real
restore.
`calculateCompliance` answers `rate: 100, totalExpected: 0` for a medication
that expects no dose. Arithmetically honest, and a lie on a surface that
reads the number as adherence — it also inflated the averages the AI prompts
are built from. The doctor report has excluded both the PRN arm and the
empty-schedule arm since it was written, with the reason beside it. The
insights envelope, the blood-pressure gate and the medication-compliance
snapshot filtered only PRN, and the empty schedule is reachable:
`POST /api/medications` accepts `schedules ?? []` on a scheduled medication.
Move both arms into one predicate, `expectsDoses`, and route all four
surfaces through it so they cannot drift apart again.
The mood page called the plain `pearsonCorrelation` — strength banded from
|r| alone above a floor of five pairs, no significance test — so five days
of noise sloping the right way arrived at the reader as a "Strong" badge,
with n and r behind an info icon. The guarded twin was written for exactly
that failure and names it in its own comment, and the weight, blood-pressure
and mood-status surfaces had already moved onto it. The mood page had not.
Route the page's five cross-metric channels through
`significantPearsonCorrelation` (n >= 20 AND p < 0.05) and carry the reason
a pairing was refused, so the card can distinguish "still building" from
"twenty days and nothing in them" instead of showing the same blank for
both. New copy in all seven locales.
Add a structural guard that fails when a new caller of the unguarded helper
appears outside a written allowlist. Two callers remain on it, each named as
open work rather than as a blessing: the /insights envelope and the AI
feature payload both still band at n >= 5, and moving them changes payload
shapes their own tests pin.