feat(arrivals): add the data-arrival event spine

Every ingest path in the product can now emit a typed, salience-classified
event into pg-boss, consumed by one worker that claims a durable per-day
reaction marker and fans out to the reaction surfaces.

The recency classifier runs at the seam, before anything is enqueued, so a
mass backfill — a multi-year Apple Health export, a provider re-sync, a
30-day catch-up — emits nothing at all. Non-salient writes are annotated and
dropped.

The worker makes no provider call by construction: it imports no provider
module, writes only markers, and enqueues nothing that is not budget-gated
downstream. Business refusals return a status rather than throwing, so a
refusal is never retried against a ceiling that will not move until the local
day rolls over.

Seams: sleep (one hook, inherited by all seven transports), workouts (the
batch route, the four provider syncs, the export importer), weight and blood
pressure (both measurement routes), labs (manual, OCR commit, document fact
commit).

The provider workout syncs could not previously tell a created row from an
updated one, so a re-sync of a recent session would have re-fired on every
poll; they now recover the distinction from the row the upsert already
returns.

Reaction lines are AES-256-GCM at rest, following the InsightNarrative
precedent, and the column is registered for key rotation. Markers are pruned
after 14 days by a daily maintenance job.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:17:34 +02:00
1 parent 571e415eaa
commit 5d62236412
25 files changed
+1302 -28

No files matched your search

+224
View File
@@ -0,0 +1,224 @@
/**
* Generator-free enqueue contract for the `data-arrival` queue.
*
* Mirrors the `morning-digest-refresh-shared.ts` split exactly: queue name +
* payload + `boss.send` live here, with NO import of the worker module, so a
* sleep sync, a workout batch, or a lab write never drags the reaction tree
* (and its transitive insight imports) into its own module graph. The worker
* lives in `@/lib/jobs/data-arrival` and re-exports the queue name from here so
* there is one source of truth.
*
* Cost discipline at the seam, because this code runs in front of every ingest
* path in the product:
*
* - Called ONCE per write batch, never per row. The caller passes the newest
* sample it inserted and how many rows it inserted; the spine never walks a
* batch.
* - No row reads. The only lookups are `resolveUserTimezone` and
* `resolveModuleMap`, both process-cached per user, and both already on the
* hot path of the seams this joins.
* - Fire-and-forget and fully try/caught. An emit failure can NEVER fail the
* ingest that called it — the discipline of `maybeEnqueueMorningRefresh`
* (`morning-refresh-trigger.ts`, its outer catch). Callers still
* `void emitDataArrival(...).catch(() => {})` for defence in depth.
* - Non-salient never reaches the queue. A backfill is annotated and dropped
* at the seam, so trigger volume is observable before any surface consumes
* the spine.
*/
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { arrivalLocalDate, classifyArrival } from "./salience";
import type { ArrivalKind, DataArrival } from "./types";
export const DATA_ARRIVAL_QUEUE = "data-arrival";
/**
* Device double-post window for the one kind whose key is not day-scoped.
*
* `workout` is keyed by the workout id rather than the local date, because two
* different workouts on one day are two different events and must both react.
* That key is re-fireable, so it takes a time window the way
* `insight-status-generate-shared.ts` does. Five minutes covers a watch
* posting the same session twice during a sync retry.
*
* Every OTHER kind is day-scoped and takes NO window — see the table in
* `arrivalSendOptions` for why a wall-clock throttle is the wrong tool there.
*/
const WORKOUT_SINGLETON_SECONDS = 300;
export interface EmitArrivalInput {
userId: string;
kind: ArrivalKind;
/** The newest sample this write actually inserted. */
newestSampleAt: Date;
/**
* Rows INSERTED by this write. An upsert that merely updated an existing row
* MUST pass 0 — that is what keeps a re-sync silent.
*/
insertedCount: number;
/** Workout id for `workout`; the panel's collection day for `labs_panel`. */
refId?: string;
/** Transport token, for annotations only. Never used for a decision. */
source: string;
/** Injectable for tests; production always uses the real clock. */
now?: Date;
}
/**
* The module each kind depends on. A user who turned the domain off gets no
* events for it — checked at the seam so a disabled module costs one cached
* map lookup rather than a queued job.
*
* `weight` and `blood_pressure` are CORE domains (always on, see
* `CORE_DOMAIN_KEYS` in `@/lib/modules/registry`), so they name no gate.
*/
const KIND_MODULE: Partial<Record<ArrivalKind, "sleep" | "workouts" | "labs">> =
{
sleep_night: "sleep",
workout: "workouts",
labs_panel: "labs",
};
/**
* Per-kind de-duplication key + window.
*
* The day-scoped kinds take a day-unique key and NO `singletonSeconds`, which
* is the shape `morning-digest-refresh-shared.ts` documents at length: a
* `singletonSeconds` window is a `floor()` over WALL-CLOCK time, so it can
* neither express a user's local date nor line up with one. It would both split
* a single local day across two buckets and merge two local days into one. The
* local date already lives in the key; the queue policy just has to honour it.
*
* Honouring it is not automatic. Under pg-boss's default `standard` policy NO
* index covers `singleton_key`, so a bare key coalesces NOTHING. The queue is
* therefore registered `exclusive` in `reminder/register-status.ts`; if that
* entry is ever removed these keys go silently inert. The two belong together.
*/
function arrivalSendOptions(
kind: ArrivalKind,
userId: string,
localDate: string,
refId: string | undefined,
): Record<string, unknown> {
const base = {
// The worker is cheap and provider-free, so it takes the morning-refresh
// retry policy rather than the slower LLM one.
retryLimit: 2,
retryDelay: 60,
retryBackoff: true,
};
if (kind === "workout") {
return {
...base,
singletonKey: `arrival:${userId}:workout:${refId ?? localDate}`,
singletonSeconds: WORKOUT_SINGLETON_SECONDS,
};
}
return {
...base,
singletonKey: `arrival:${userId}:${kind}:${localDate}`,
};
}
/**
* Classify a write, and enqueue a `DataArrival` only if it is genuinely news.
*
* Returns nothing and throws nothing. Every exit annotates, so the volume of
* emitted vs. skipped events is visible in wide events before any surface
* consumes the spine.
*/
export async function emitDataArrival(input: EmitArrivalInput): Promise<void> {
const { userId, kind, newestSampleAt, insertedCount, refId, source } = input;
const now = input.now ?? new Date();
try {
const moduleKey = KIND_MODULE[kind];
if (moduleKey) {
const modules = await resolveModuleMap(userId);
if (modules[moduleKey] === false) {
annotate({
action: { name: `arrival.${kind}.skipped` },
meta: { reason: "module_off", source },
});
return;
}
}
const tz = await resolveUserTimezone(userId);
const classification = classifyArrival({
kind,
newestSampleAt,
insertedCount,
now,
tz,
});
if (classification !== "salient") {
// The diagnostic gate. A mass import lands here for every one of its
// batches, so the meta carries enough to tell a backfill apart from a
// timezone mistake without a code change.
annotate({
action: { name: `arrival.${kind}.skipped` },
meta: {
reason: classification,
source,
tz,
inserted: insertedCount,
sample_local_date:
classification === "backfill"
? arrivalLocalDate(newestSampleAt, tz)
: undefined,
},
});
return;
}
const localDate = arrivalLocalDate(now, tz);
const payload: DataArrival = {
userId,
kind,
salience: "salient",
localDate,
occurredAt: newestSampleAt.toISOString(),
refId,
count: insertedCount,
source,
};
const boss = getGlobalBoss();
if (!boss) {
// A web process without an embedded worker. Not an error: the surfaces
// this spine feeds all degrade to their deterministic form, and the next
// landing on a worker-bearing process re-emits.
annotate({
action: { name: `arrival.${kind}.skipped` },
meta: { reason: "no_boss", source, local_date: localDate },
});
return;
}
await boss.send(
DATA_ARRIVAL_QUEUE,
payload,
arrivalSendOptions(kind, userId, localDate, refId),
);
annotate({
action: { name: `arrival.${kind}.emitted` },
meta: {
salience: "salient",
count: insertedCount,
source,
local_date: localDate,
},
});
} catch {
// Never let a reaction trigger fail an ingest. The data is already written;
// the reaction is garnish, and the next landing is the catch-net.
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Measurement type → arrival kind mapping, shared by the three measurement
* write seams (the batch route, and both arms of the interactive POST).
*
* Kept in one place because the mapping encodes a product judgement that must
* not drift between seams: blood pressure is stored as TWO rows (a systolic and
* a diastolic sharing one `measuredAt`) but is ONE reading to every reader, so
* both types map to the same kind and a write that lands both produces a single
* arrival.
*
* Types absent from the map are simply not part of the spine. That is the
* default, and adding one is a deliberate act: a new kind also needs a surface
* that consumes it and, if it can cause a provider call, a stated per-user
* daily bound.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import type { ArrivalKind } from "./types";
export const ARRIVAL_MEASUREMENT_KIND: Partial<
Record<MeasurementType, ArrivalKind>
> = {
WEIGHT: "weight",
BLOOD_PRESSURE_SYS: "blood_pressure",
BLOOD_PRESSURE_DIA: "blood_pressure",
};
export interface ArrivalKindGroup {
newestAt: Date;
count: number;
}
/**
* Collapse just-written measurement rows into at most one group per arrival
* kind, carrying the newest timestamp and how many rows landed.
*
* The newest timestamp is what the classifier tests for recency, so a write
* mixing a backfilled row with a fresh one is judged on the fresh one — which
* is correct: the record genuinely did just gain something current.
*/
export function groupRowsByArrivalKind(
rows: ReadonlyArray<{ type: MeasurementType; measuredAt: Date }>,
): Array<[ArrivalKind, ArrivalKindGroup]> {
const groups = new Map<ArrivalKind, ArrivalKindGroup>();
for (const row of rows) {
const kind = ARRIVAL_MEASUREMENT_KIND[row.type];
if (!kind) continue;
if (Number.isNaN(row.measuredAt.getTime())) continue;
const existing = groups.get(kind);
if (!existing) {
groups.set(kind, { newestAt: row.measuredAt, count: 1 });
continue;
}
existing.count++;
if (row.measuredAt.getTime() > existing.newestAt.getTime()) {
existing.newestAt = row.measuredAt;
}
}
return [...groups];
}
+105
View File
@@ -0,0 +1,105 @@
/**
* The anti-storm core of the arrival spine: a pure, timezone-honest recency
* classifier that runs AT THE SEAM, before anything is enqueued.
*
* This is the one guardrail the whole spine rests on. Every ingest path in the
* product is also a BACKFILL path: a ten-year Apple Health export, a WHOOP
* re-sync after a token refresh, a 30-day catch-up after downtime, a `stats:`
* overwrite storm from the iOS client. Without a recency test at the seam,
* those would each turn into thousands of queued jobs and, downstream, provider
* calls. With it, they emit exactly zero events: every sample fails the test
* and is annotated, never enqueued.
*
* It generalizes `isLastNightLocal` (`@/lib/daily/morning-refresh-trigger`),
* the predicate the S4 sleep trigger has already proven against real backfills,
* to every arrival kind. Pure and timezone-explicit — no DB, no ambient clock —
* so the timezone-boundary cases are unit-testable directly.
*/
import { userDayKey } from "@/lib/tz/format";
import type { ArrivalKind } from "./types";
const MS_PER_DAY = 86_400_000;
/**
* What the seam decided about a candidate arrival.
*
* - `salient` — new data from today or yesterday (locally); enqueue.
* - `backfill` — historical or future-dated data; annotate and drop.
* - `noop` — the write landed no new rows; annotate and drop.
*/
export type ArrivalClassification = "salient" | "backfill" | "noop";
export interface ClassifyArrivalInput {
kind: ArrivalKind;
/** The newest sample the write actually inserted. */
newestSampleAt: Date;
/** Rows INSERTED — an upsert that only updated values must pass 0 here. */
insertedCount: number;
now: Date;
/** The user's PROFILE timezone. Never the server's, never UTC. */
tz: string;
}
/**
* Classify a candidate arrival. Deterministic and total — every input lands in
* exactly one of the three buckets.
*
* Ordering matters:
*
* 1. `insertedCount <= 0` → `noop`. A pure re-sync or a `stats:` overwrite
* changed values but landed no new reading. Value changes already refresh
* the cards through `@/lib/insights/status-invalidation`; the spine reacts
* to NEW data only, so it must not fire here.
* 2. Future-dated sample → `backfill`. Clock skew on a device, not news.
* Rejected the same way `isLastNightLocal` rejects it.
* 3. Local calendar day older than yesterday → `backfill`. This is the test
* that makes a mass import free. "Older than yesterday" (rather than
* "older than today") is deliberate: a sleep night, and a late-evening
* workout synced after midnight, both belong to yesterday locally while
* still being the freshest thing the user has.
* 4. Otherwise → `salient`.
*
* Kind is carried for readability and future per-kind rules, but every kind
* shares the recency test today. The one per-kind refinement the spec calls for
* — `weight` is salient only as the FIRST reading of the local day — is NOT
* decided here: it needs a lookup, and the seam must stay free of reads. The
* worker enforces it structurally instead, via the `ArrivalReaction` unique row
* on `[userId, kind, localDate]`; a second weigh-in enqueues one cheap job that
* de-duplicates against that row and fans nothing out.
*/
export function classifyArrival(
input: ClassifyArrivalInput,
): ArrivalClassification {
const { newestSampleAt, insertedCount, now, tz } = input;
if (!Number.isFinite(insertedCount) || insertedCount <= 0) return "noop";
if (
!(newestSampleAt instanceof Date) ||
Number.isNaN(newestSampleAt.getTime())
)
return "noop";
if (newestSampleAt.getTime() > now.getTime()) return "backfill";
const todayKey = userDayKey(now, tz);
const yesterdayKey = userDayKey(new Date(now.getTime() - MS_PER_DAY), tz);
const sampleKey = userDayKey(newestSampleAt, tz);
if (sampleKey !== todayKey && sampleKey !== yesterdayKey) return "backfill";
return "salient";
}
/**
* The `localDate` an arrival is filed under: TODAY in the user's timezone, not
* the sample's own day.
*
* The day key is a claim key, not a timestamp — it answers "which day does this
* reaction belong to", and a sleep night that ended at 06:00 today, a workout
* from 23:40 yesterday synced at 00:20, and this morning's weight are all part
* of the same day's story. Filing by the sample's own day would split them and
* defeat the once-per-kind-per-day claim the unique row exists to make.
*/
export function arrivalLocalDate(now: Date, tz: string): string {
return userDayKey(now, tz);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* The data-arrival event spine — wire types.
*
* Every ingest path in the product can emit a `DataArrival` when NEW data
* lands. One worker consumes them and drives the reaction surfaces. The spine
* sits directly in front of every write seam, so its types are deliberately
* closed and small: a free-form kind would let a future ingest path enqueue an
* unbounded event class that no reader understands, and a fat payload would
* tempt a seam into reading rows it does not already hold.
*
* Mirrors the discipline of `@/lib/daily/priority-item`: wire-serialisable
* primitives only (the payload crosses a pg-boss JSON column), no Date objects,
* no Prisma types.
*/
/**
* The closed set of arrival kinds. GROWS BY PR ONLY — adding a member is a
* deliberate act that must also name the surface consuming it and, if it can
* cause a provider call, its per-user daily bound.
*/
export const ARRIVAL_KINDS = [
/** Last night's sleep completed, from any of the seven transports. */
"sleep_night",
/** A finished workout row was inserted (not re-synced). */
"workout",
/** The first weight reading of the local day. */
"weight",
/** A fresh blood-pressure reading. */
"blood_pressure",
/** A new lab panel landed (manual, OCR confirm, or auto-stage review). */
"labs_panel",
] as const;
export type ArrivalKind = (typeof ARRIVAL_KINDS)[number];
/**
* Salience tier of an emitted event. Only `"salient"` is ever enqueued today;
* the union is kept open for a future `"digest_only"` tier that would ride the
* nightly pass instead of the queue. `"backfill"` and `"noop"` classifications
* never become events at all — they are annotated at the seam and dropped.
*/
export type ArrivalSalience = "salient";
/**
* The queued payload. Everything here is known at the write seam without an
* extra read: the seam already holds the rows it just wrote.
*/
export interface DataArrival {
userId: string;
kind: ArrivalKind;
/** Deterministic, computed at the emit seam. Non-salient is never enqueued. */
salience: ArrivalSalience;
/** User-profile-tz day key of the NEWEST sample (YYYY-MM-DD). */
localDate: string;
/** ISO timestamp of the newest sample in the batch. */
occurredAt: string;
/** Kind-scoped referent: the workout id for `workout`, the panel date for labs. */
refId?: string;
/** Rows actually INSERTED (not upsert-updated) by the write that emitted. */
count: number;
/** Transport token ("withings", "apple", "manual", …) for annotations only. */
source: string;
}
/** Type guard for a kind read back off the wire. */
export function isArrivalKind(value: unknown): value is ArrivalKind {
return (
typeof value === "string" &&
(ARRIVAL_KINDS as readonly string[]).includes(value)
);
}
+72
View File
@@ -0,0 +1,72 @@
/**
* The provider-sync workout seam.
*
* The four provider syncs (WHOOP, Fitbit, Google Health, Strava) all persist a
* workout with `prisma.workout.upsert`, and an upsert cannot say whether it
* created a row or updated one. That distinction is the whole point of the
* spine: a re-sync must be silent. WHOOP alone re-posts the same recent workout
* on every poll, so without it a single day's session would emit a fresh
* arrival — and dispatch a fresh downstream reaction — every polling interval,
* indefinitely.
*
* `wasJustCreated` recovers the distinction for free, from the row the upsert
* already returned, rather than paying an existence probe per workout on what
* is also the backfill path. See its docblock for why the comparison is sound.
*/
import { emitDataArrival } from "./emit-shared";
/** The subset of a `Workout` row this seam needs back from an upsert. */
export interface UpsertedWorkoutRow {
id: string;
startedAt: Date;
createdAt: Date;
updatedAt: Date;
}
/**
* True when the upsert CREATED this row rather than updating an existing one.
*
* `createdAt` is `@default(now())` and `updatedAt` is `@updatedAt`, so Prisma
* writes both from the same statement on a create and they are byte-identical.
* Any subsequent update bumps `updatedAt` alone, leaving it strictly greater.
* The two can therefore only be equal on a row that has never been updated,
* which — on an upsert path where every update writes the full row — means the
* upsert just created it.
*
* The alternative is an indexed existence probe before every upsert, which is
* the pattern `import-apple-health-export.ts` uses because it can batch the
* probe across a chunk. These seams upsert one workout at a time, so a probe
* would double the query count on exactly the path (a provider backfill) that
* most needs to stay cheap.
*/
export function wasJustCreated(row: {
createdAt: Date;
updatedAt: Date;
}): boolean {
return row.createdAt.getTime() === row.updatedAt.getTime();
}
/**
* Emit a workout arrival for a just-upserted row, but only if the upsert
* actually created it. Best-effort: never throws, never fails the sync.
*
* The recency classifier still runs inside `emitDataArrival`, so a provider
* backfill that legitimately CREATES hundreds of historical rows still emits
* zero events — the created-vs-updated test and the recency test are two
* independent gates, and a backfill fails the second one.
*/
export async function emitWorkoutArrivalIfCreated(
userId: string,
row: UpsertedWorkoutRow,
source: string,
): Promise<void> {
if (!wasJustCreated(row)) return;
await emitDataArrival({
userId,
kind: "workout",
newestSampleAt: row.startedAt,
insertedCount: 1,
refId: row.id,
source,
});
}