mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
* feat(drug-reference): offline FDA drug labels, conditions, and remedies
Adds an offline medical-reference feature with three coupled layers:
- Drug Reference: full-text search over openFDA drug-label indications,
a detail page per label, and a side-by-side single-drug comparison view.
A two-phase background pipeline downloads the openFDA label parts to the
storage volume (resumable) and ingests them into the search table.
- Conditions ("When to use what"): a curated spine of first-aid situations
that maps each situation to matching OTC drugs, each linking back to its
Drug Reference detail page.
- Curated remedies: hand-authored natural and home-remedy entries drawn from
US-government public-domain sources (NCCIH, CDC, MedlinePlus, FDA), shown
with their source links and the same safety disclaimers as the rest of the
feature.
The drug-reference and conditions layers are intentionally coupled: the drug
detail page shows the situations a drug treats, and the conditions controller
reads the same drug_labels table.
Safety surfaces ship as written. The amber SafetyBanner ("informational only,
not medical advice, not an FDA endorsement, not a drug-interaction checker, in
an emergency call emergency services") renders on the condition pages and the
search page; the detail and comparison pages carry their own "not a cross-drug
interaction checker" callout; and every page carries the openFDA CC0 source
citation and no-FDA-affiliation footer.
Wiring on this branch:
- start/routes.ts: the /drug-reference and /conditions page GETs plus their
/api/* groups.
- commands/queue/work.ts: the drug-download and drug-ingest queues, both at
concurrency 1. The two drug queues get a per-queue stall override
(lockDuration 1_800_000, maxStalledCount 3) because each part is one long
stream; every other queue keeps the existing 300000 default.
- inertia/pages/home.tsx: Drug Reference and "When to use what" tiles. The
icon and display_order are a starting point, open to change.
- types/kv_store.ts: the two drugReference.* keys the pipeline reads and writes.
- package.json: yauzl and stream-json (plus their @types), used by the ingest
job to stream the label JSON out of the downloaded zips.
The app reads the conditions and remedy data from the compiled TS constants in
app/data/; the repo-root collections/*.json files are the browseable mirrors.
The natural-remedies standalone test reads collections/natural_remedies.json to
assert the two stay in sync, so that file is also a test fixture.
The four standalone tests pass (drug_interactions, drug_ingest_status,
conditions, natural_remedies). tsc reports no errors in the feature code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(drug-reference): drop a fork-internal comment reference
* fix(drug-reference): address review — defer FULLTEXT, fallback remedies prop, strip fork refs
- migration: wrap the FULLTEXT ALTER in this.defer so it runs after the
deferred createTable (was silently swallowed, index never created)
- controller: add remedies:[] to the index() error fallback (required prop)
- strip fork-internal issue/spec references from ported comments
- tsconfig: exclude tests/standalone (node --experimental-strip-types only)
- correct the varchar(768) byte-math comment; extend remedy-spine test
* fix(drug-reference): make the interaction comparison readable at five drugs
The comparison view laid its columns out on an equal-fraction CSS grid
(repeat(N, minmax(0, 1fr))), so each added drug shrank every column; at the
five-drug maximum the FDA interaction text was squeezed into unreadable slivers.
Lay the columns out with flex instead: full-width and stacked on phones, then
fixed-width columns that scroll sideways from the sm: breakpoint up, so they
never shrink below a readable width. Theme the columns with the same palette as
the rest of the page (they were on stock gray), and give the headers a fixed
min-height so columns line up when drug names wrap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(collections): route a 'dataset' tier resource to the drug pipeline
Add an optional `type` discriminator to SpecResource ('zim' | 'dataset',
absent == 'zim'), so the tier installer can carry a DB-ingested resource
alongside ZIM files. ZimService.downloadCategoryTier branches on it: a
'dataset' resource dispatches the existing FDA download+ingest pipeline
instead of RunDownloadJob, guarded against duplicate dispatch by the drug
ingest status. Every existing manifest entry has no `type` and keeps the
exact ZIM path.
Widen InstalledResource.resource_type to include 'dataset' and exclude
dataset rows from the ZIM/map catalog-update scan (datasets aren't
filename-versioned; their freshness path is separate). No dataset rows are
written yet: the InstalledResource 'dataset' row on ingest-ready, the
manifest entry, install-gating, and the downloads-aggregator integration
are follow-up commits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): rework into an opt-in medicine-standard tier dataset
Reshapes the offline FDA drug reference from a built-in feature into curated
content installed by selecting the Medicine / Standard tier, per maintainer
direction.
- install-state: the ingest writes an installed_resources 'dataset' row on
ready (version = the openFDA export_date), threaded installer to download to
ingest; the tier-status math and the home-tile gate read it. Manual ingests
write no row, so install-state stays tied to the curated path.
- manifest: declare the dataset in the medicine-standard tier (runtime fetches
the remote manifest, so this also needs to land upstream).
- install-gating: the drug-reference home tiles render only when installed.
- uninstall: DrugReferenceService.uninstall() stops the two drug queues, deletes
the on-disk parts, truncates drug_labels (schema kept), clears the KV markers,
and drops the install row. Best-effort, logged, scoped to drug data only.
- downloads: the download phase reports the canonical {percent, downloadedBytes,
totalBytes} shape as one drug-data card in the Active Downloads aggregator with
cancel/remove; the heavy ingest stays in the IngestStatus surface with an
Indexing handoff on the card.
- auto-update: a daily DrugAutoUpdateJob compares the manifest export_date and
re-downloads when newer, gated on installed + no active job.
typecheck clean; the drug standalone suites pass. Three points are flagged in
code for the maintainer: the InstalledResource 'dataset' approach, the tier
home, and the export_date string format.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(downloads): keep the drug download card live across all parts
The Active Downloads card filtered to the deterministic jobId, but the
download's continuations run under auto-generated jobIds (only part 0 uses the
deterministic one). So the card tracked part 0 and then vanished while parts
2..N kept downloading. The queue is concurrency 1, so collapse to whichever
single part is in flight and report the deterministic jobId: one card tracks
aggregate progress through the whole download and cancel/remove still routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): co-locate a persistent safety note with affirmative remedy guidance
Add RemedySafetyNote at the head of every natural-remedy section — the two on
Drug Reference and the one on "When to use what" — so the "informational only,
not medical advice, seek real medical care in an emergency" framing appears with
the guidance itself, not only in the page-top banner. Replaces the terse
per-section caveat with the same amber alert language as SafetyBanner.
Addresses the upstream #1040 review request that the disclaimer be unmistakable
and present wherever affirmative self-care guidance appears, not a one-time banner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(drug-reference): fold dataset freshness into the content-auto-update path
The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.
Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.
Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): strengthen the remedy note to consult a clinician before combining with meds
Widen the affirmative-remedy safety note from "talk to a clinician before use"
to explicitly cover using a remedy AND combining one with a medication the user
already takes — the interaction case is the higher-risk path for an off-grid
user self-treating.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(drug-reference): gate affirmative remedy content behind an off-by-default flag
Add drugReference.remediesEnabled (default off), independent of the tier
install. When off, the server emits no remedy data at any boundary — the
drug-reference page prop, conditions show, and the /api/conditions/drugs
situation search — and the "Natural" filter is hidden, so installing the
medicine-standard tier lights up the verbatim FDA label search and the
condition-to-OTC matching but not the hand-authored self-care and herbal
sections. No user-facing toggle: it is flipped on after a clinician content-pass.
Implements the upstream #1040 split-by-risk request: the regulated label content
ships with the tier; the authored remedy guidance stays gated until sign-off.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jake Turner <52841588+jakeaturner@users.noreply.github.com>
600 lines
24 KiB
TypeScript
600 lines
24 KiB
TypeScript
import logger from '@adonisjs/core/services/logger'
|
|
import { DateTime } from 'luxon'
|
|
import KVStore from '#models/kv_store'
|
|
import InstalledResource from '#models/installed_resource'
|
|
import { DownloadService } from '#services/download_service'
|
|
import { CollectionUpdateService } from '#services/collection_update_service'
|
|
import { DrugReferenceService } from '#services/drug_reference_service'
|
|
import {
|
|
KiwixCatalogService,
|
|
reconcileResourceUpdateState,
|
|
type CatalogResult,
|
|
} from '#services/kiwix_catalog_service'
|
|
import { isWithinWindow, parseWindowMinutes } from '../utils/update_window.js'
|
|
import { recordResourceUpdateFailure } from '../utils/content_auto_update_backoff.js'
|
|
import type { Blocker, PreflightResult } from '../utils/image_disk_preflight.js'
|
|
|
|
/**
|
|
* Content auto-update is opt-in via a single global master switch and runs on
|
|
* its OWN window + per-window data cap (deliberately separate from the core/app
|
|
* `autoUpdate.*` window, since ZIM downloads are multi-GB and bandwidth
|
|
* sensitive). Defaults err toward an overnight window with no cap; the UI
|
|
* strongly recommends setting a cap.
|
|
*/
|
|
const DEFAULT_WINDOW_START = '02:00'
|
|
const DEFAULT_WINDOW_END = '05:00'
|
|
const DEFAULT_COOLOFF_HOURS = 72
|
|
|
|
/** Whole-feature failures (e.g. catalog unreachable) before it self-disables. */
|
|
const MAX_FEATURE_FAILURES = 3
|
|
|
|
export interface ContentAutoUpdateConfig {
|
|
/** Global master switch (`contentAutoUpdate.enabled`). */
|
|
enabled: boolean
|
|
windowStart: string
|
|
windowEnd: string
|
|
cooloffHours: number
|
|
/** Max NEW bytes initiated per window instance. 0 = unlimited. */
|
|
maxBytesPerWindow: number
|
|
}
|
|
|
|
/** Per-resource eligibility verdict (drives both selection and the status UI). */
|
|
export interface ContentEligibility {
|
|
eligible: boolean
|
|
reason: string
|
|
cooloffRemainingHours: number | null
|
|
}
|
|
|
|
/** An eligible resource paired with the catalog facts needed to download it. */
|
|
export interface ContentCandidate {
|
|
resource: InstalledResource
|
|
version: string
|
|
download_url: string
|
|
size_bytes: number
|
|
installed_at: DateTime
|
|
}
|
|
|
|
/** Outcome of the cap-bounded greedy selection. */
|
|
export interface ContentSelection {
|
|
selected: ContentCandidate[]
|
|
/** Single files larger than the whole cap — never auto-started (manual only). */
|
|
skippedOversize: ContentCandidate[]
|
|
/** Fit the cap but not this window's remaining budget — retried next window. */
|
|
deferred: ContentCandidate[]
|
|
}
|
|
|
|
export interface ContentAutoUpdateResourceStatus {
|
|
resource_id: string
|
|
resource_type: 'zim' | 'map'
|
|
current_version: string
|
|
available_update_version: string | null
|
|
size_bytes: number | null
|
|
eligible: boolean
|
|
reason: string
|
|
cooloff_remaining_hours: number | null
|
|
exceeds_cap: boolean
|
|
consecutive_failures: number
|
|
auto_disabled_reason: string | null
|
|
}
|
|
|
|
export interface ContentAutoUpdateStatus extends ContentAutoUpdateConfig {
|
|
withinWindow: boolean
|
|
windowBytesUsed: number
|
|
lastAttemptAt: string | null
|
|
lastResult: string | null
|
|
lastError: string | null
|
|
autoDisabledReason: string | null
|
|
resources: ContentAutoUpdateResourceStatus[]
|
|
}
|
|
|
|
/**
|
|
* Decision + safety layer for automatic content (ZIM/map) updates. This is the
|
|
* content-side counterpart to {@link AppAutoUpdateService}: it decides *whether*
|
|
* each installed resource with an available update should be downloaded now
|
|
* (master switch on + in the content window + past cool-off + within the data
|
|
* cap) and then drives the existing manual download path
|
|
* ({@link CollectionUpdateService.applyUpdate} → {@link RunDownloadJob}).
|
|
*
|
|
* It never installs synchronously — it dispatches resumable download jobs and
|
|
* lets the existing job-completion path advance the installed version and
|
|
* rebuild the Kiwix library.
|
|
*/
|
|
export class ContentAutoUpdateService {
|
|
constructor(
|
|
private downloadService: DownloadService,
|
|
private catalog: KiwixCatalogService = new KiwixCatalogService(),
|
|
private collectionUpdateService: CollectionUpdateService = new CollectionUpdateService()
|
|
) {}
|
|
|
|
/** Read the master switch plus the content-specific window/cool-off/cap. */
|
|
async getConfig(): Promise<ContentAutoUpdateConfig> {
|
|
const [enabled, windowStart, windowEnd, cooloffHours, maxBytes] = await Promise.all([
|
|
KVStore.getValue('contentAutoUpdate.enabled'),
|
|
KVStore.getValue('contentAutoUpdate.windowStart'),
|
|
KVStore.getValue('contentAutoUpdate.windowEnd'),
|
|
KVStore.getValue('contentAutoUpdate.cooloffHours'),
|
|
KVStore.getValue('contentAutoUpdate.maxBytesPerWindow'),
|
|
])
|
|
|
|
const parsedCooloff = Number(cooloffHours)
|
|
const parsedCap = Number(maxBytes)
|
|
return {
|
|
enabled: enabled ?? false,
|
|
windowStart: windowStart || DEFAULT_WINDOW_START,
|
|
windowEnd: windowEnd || DEFAULT_WINDOW_END,
|
|
// `Number(null) === 0`, so an unset value must fall through to the default
|
|
// rather than silently resolving to a zero cool-off. An explicit 0 is honored.
|
|
cooloffHours:
|
|
cooloffHours !== null && Number.isFinite(parsedCooloff) && parsedCooloff >= 0
|
|
? parsedCooloff
|
|
: DEFAULT_COOLOFF_HOURS,
|
|
maxBytesPerWindow:
|
|
maxBytes !== null && Number.isFinite(parsedCap) && parsedCap >= 0 ? parsedCap : 0,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pure per-resource eligibility verdict. A resource is eligible when it has a
|
|
* detected newer version, is not self-disabled, and is past its cool-off
|
|
* (measured from first-detected). Version comparison is a lexicographic
|
|
* compare of the YYYY-MM stamps, which sorts chronologically.
|
|
*/
|
|
resourceEligibility(
|
|
resource: InstalledResource,
|
|
cooloffHours: number,
|
|
now: DateTime
|
|
): ContentEligibility {
|
|
if (!resource.available_update_version) {
|
|
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
|
|
}
|
|
if (resource.auto_update_disabled_reason) {
|
|
return {
|
|
eligible: false,
|
|
reason: 'Auto-update disabled after repeated failures',
|
|
cooloffRemainingHours: null,
|
|
}
|
|
}
|
|
if (!(resource.available_update_version > resource.version)) {
|
|
return { eligible: false, reason: 'Up to date', cooloffRemainingHours: null }
|
|
}
|
|
if (!resource.available_update_first_seen_at) {
|
|
return { eligible: false, reason: 'Cool-off pending', cooloffRemainingHours: cooloffHours }
|
|
}
|
|
|
|
const ageHours = now.diff(resource.available_update_first_seen_at, 'hours').hours
|
|
const remaining = cooloffHours - ageHours
|
|
if (remaining > 0) {
|
|
const rounded = Math.ceil(remaining)
|
|
return {
|
|
eligible: false,
|
|
reason: `In cool-off (${rounded}h remaining)`,
|
|
cooloffRemainingHours: rounded,
|
|
}
|
|
}
|
|
|
|
return {
|
|
eligible: true,
|
|
reason: `Eligible → ${resource.available_update_version}`,
|
|
cooloffRemainingHours: 0,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pure cap-bounded greedy selection. Oldest-installed first (stale content is
|
|
* prioritized), tie-broken smallest-first for predictability.
|
|
*
|
|
* - size unknown (0) → deferred (can't budget safely)
|
|
* - size > the WHOLE cap → skippedOversize (never auto-started; manual only)
|
|
* - size ≤ remaining budget → selected
|
|
* - otherwise → deferred (fits the cap, not this window)
|
|
*/
|
|
selectUnderCap(
|
|
candidates: ContentCandidate[],
|
|
capBytes: number,
|
|
usedBytes: number
|
|
): ContentSelection {
|
|
const cap = capBytes > 0 ? capBytes : Number.POSITIVE_INFINITY
|
|
let remaining = Math.max(0, cap - usedBytes)
|
|
|
|
const selected: ContentCandidate[] = []
|
|
const skippedOversize: ContentCandidate[] = []
|
|
const deferred: ContentCandidate[] = []
|
|
|
|
const ordered = [...candidates].sort((a, b) => {
|
|
const at = a.installed_at?.toMillis?.() ?? 0
|
|
const bt = b.installed_at?.toMillis?.() ?? 0
|
|
if (at !== bt) return at - bt
|
|
return a.size_bytes - b.size_bytes
|
|
})
|
|
|
|
for (const candidate of ordered) {
|
|
if (candidate.size_bytes <= 0) {
|
|
deferred.push(candidate)
|
|
} else if (candidate.size_bytes > cap) {
|
|
skippedOversize.push(candidate)
|
|
} else if (candidate.size_bytes <= remaining) {
|
|
selected.push(candidate)
|
|
remaining -= candidate.size_bytes
|
|
} else {
|
|
deferred.push(candidate)
|
|
}
|
|
}
|
|
|
|
return { selected, skippedOversize, deferred }
|
|
}
|
|
|
|
/**
|
|
* Run-wide pre-flight: never auto-update content while ANY download is already
|
|
* running. Because content downloads are multi-GB and resumable, an in-flight
|
|
* download from a prior window naturally blocks new starts here — exactly the
|
|
* "let in-flight finish, don't start new" behavior we want. Transient → `skip`.
|
|
*/
|
|
async runGlobalPreflight(): Promise<PreflightResult> {
|
|
const blockers: Blocker[] = []
|
|
try {
|
|
const downloads = await this.downloadService.listDownloadJobs()
|
|
const active = downloads.filter(
|
|
(d) => !!d.status && ['waiting', 'active', 'delayed'].includes(d.status)
|
|
)
|
|
if (active.length > 0) {
|
|
blockers.push({ reason: `${active.length} download(s) in progress`, severity: 'skip' })
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
logger.warn(`[ContentAutoUpdateService] Could not check active downloads: ${message}`)
|
|
}
|
|
return { ok: blockers.length === 0, blockers }
|
|
}
|
|
|
|
/**
|
|
* Entry point invoked by ContentAutoUpdateJob. Gates on the master switch +
|
|
* window, runs the local catalog check, then downloads as many eligible
|
|
* resources as fit under the per-window data cap.
|
|
*/
|
|
async attempt(): Promise<{ started: number; reason: string }> {
|
|
const config = await this.getConfig()
|
|
const now = DateTime.now()
|
|
|
|
if (!config.enabled) {
|
|
return { started: 0, reason: 'Content auto-update is disabled' }
|
|
}
|
|
if (!isWithinWindow(config.windowStart, config.windowEnd, now)) {
|
|
const reason = `Outside update window (${config.windowStart}-${config.windowEnd})`
|
|
await this.recordRun(reason)
|
|
return { started: 0, reason }
|
|
}
|
|
|
|
try {
|
|
// Reset the per-window budget once per window instance (the cron fires
|
|
// hourly but a window can span several hours).
|
|
await this.maybeResetWindowBudget(config, now)
|
|
|
|
// Local catalog check + persist available-update state for every resource.
|
|
// ZIM/map catalog path only — `dataset` resources are excluded here because
|
|
// their freshness key (openFDA `export_date`) and apply path (the drug
|
|
// download/ingest chain) don't fit the catalog-version model. They refresh
|
|
// via `attemptDrugDataset()`, which the same content-update job runs under
|
|
// the same master switch + window.
|
|
const installed = await InstalledResource.query().whereNot('resource_type', 'dataset')
|
|
const latestByKey = await this.catalog.getLatestForResources(
|
|
// `dataset` rows are filtered out above, so the type narrows to ZIM/map.
|
|
installed.map((r) => ({
|
|
resource_id: r.resource_id,
|
|
resource_type: r.resource_type as 'zim' | 'map',
|
|
}))
|
|
)
|
|
for (const resource of installed) {
|
|
const latest = latestByKey.get(`${resource.resource_type}:${resource.resource_id}`) ?? null
|
|
await reconcileResourceUpdateState(resource, latest, now)
|
|
}
|
|
|
|
const eligible = installed.filter(
|
|
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
|
|
)
|
|
if (eligible.length === 0) {
|
|
await this.recordFeatureSuccess()
|
|
const reason = 'No eligible content updates'
|
|
await this.recordRun(reason)
|
|
return { started: 0, reason }
|
|
}
|
|
|
|
const global = await this.runGlobalPreflight()
|
|
if (!global.ok) {
|
|
await this.recordFeatureSuccess()
|
|
const reason = `Pre-flight blocked: ${global.blockers.map((b) => b.reason).join('; ')}`
|
|
await this.recordRun(reason)
|
|
return { started: 0, reason }
|
|
}
|
|
|
|
const candidates: ContentCandidate[] = eligible.map((r) => {
|
|
const latest = latestByKey.get(`${r.resource_type}:${r.resource_id}`) as CatalogResult
|
|
return {
|
|
resource: r,
|
|
version: latest.version,
|
|
download_url: latest.download_url,
|
|
size_bytes: r.available_update_size_bytes ?? latest.size_bytes ?? 0,
|
|
installed_at: r.installed_at,
|
|
}
|
|
})
|
|
|
|
const usedBytes = await this.getWindowBytesUsed()
|
|
const { selected, skippedOversize, deferred } = this.selectUnderCap(
|
|
candidates,
|
|
config.maxBytesPerWindow,
|
|
usedBytes
|
|
)
|
|
|
|
let started = 0
|
|
let failed = 0
|
|
let initiatedBytes = 0
|
|
for (const candidate of selected) {
|
|
const result = await this.collectionUpdateService.applyUpdate(
|
|
{
|
|
resource_id: candidate.resource.resource_id,
|
|
// `dataset` rows are filtered out of `installed` above, so ZIM/map.
|
|
resource_type: candidate.resource.resource_type as 'zim' | 'map',
|
|
installed_version: candidate.resource.version,
|
|
latest_version: candidate.version,
|
|
download_url: candidate.download_url,
|
|
size_bytes: candidate.size_bytes || undefined,
|
|
},
|
|
{ auto: true }
|
|
)
|
|
|
|
if (result.success) {
|
|
// Success is NOT recorded here: applyUpdate only enqueues a resumable
|
|
// download. The per-resource backoff is cleared once the download
|
|
// actually completes (RunDownloadJob.onComplete) and incremented when it
|
|
// fails terminally (the worker `failed` handler). Recording success on
|
|
// dispatch would reset the counter every window and defeat self-disable.
|
|
initiatedBytes += candidate.size_bytes
|
|
started++
|
|
logger.info(
|
|
`[ContentAutoUpdateService] Started ${candidate.resource.resource_id} → ${candidate.version}`
|
|
)
|
|
} else {
|
|
// A failure to even enqueue is a genuine auto-update failure; no job runs,
|
|
// so no terminal `failed` event will follow — count it here.
|
|
await recordResourceUpdateFailure(candidate.resource, result.error ?? 'dispatch failed')
|
|
failed++
|
|
}
|
|
}
|
|
|
|
if (initiatedBytes > 0) {
|
|
await this.addWindowBytesUsed(initiatedBytes)
|
|
}
|
|
|
|
const parts = [`${started} started`]
|
|
if (failed) parts.push(`${failed} failed`)
|
|
if (skippedOversize.length) parts.push(`${skippedOversize.length} skipped (exceeds cap)`)
|
|
if (deferred.length) parts.push(`${deferred.length} deferred (over budget)`)
|
|
const reason = parts.join(', ')
|
|
|
|
await this.recordFeatureSuccess()
|
|
await this.recordRun(reason)
|
|
logger.info(`[ContentAutoUpdateService] Run complete: ${reason}`)
|
|
return { started, reason }
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
await this.recordFeatureFailure(message)
|
|
await this.recordRun(`Failed: ${message}`)
|
|
logger.error(`[ContentAutoUpdateService] Run failed: ${message}`)
|
|
return { started: 0, reason: `Failed: ${message}` }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Freshness pass for the FDA drug dataset (`resource_type` 'dataset'), run by
|
|
* the same hourly ContentAutoUpdateJob as the ZIM/map `attempt()` and gated on
|
|
* the SAME master switch + window. The dataset's apply path differs from the
|
|
* catalog loop (openFDA `export_date` + the drug download/ingest chain rather
|
|
* than an InstalledResource catalog-version row), so it runs as its own step
|
|
* instead of through `selectUnderCap` — but sharing `contentAutoUpdate.*` means
|
|
* it never updates while content auto-update is off, and refreshes alongside
|
|
* ZIMs and maps when it is on. Isolated so a drug-side failure can't affect the
|
|
* catalog run.
|
|
*/
|
|
async attemptDrugDataset(): Promise<{ started: number; reason: string }> {
|
|
const config = await this.getConfig()
|
|
if (!config.enabled) {
|
|
return { started: 0, reason: 'Content auto-update is disabled' }
|
|
}
|
|
if (!isWithinWindow(config.windowStart, config.windowEnd, DateTime.now())) {
|
|
return {
|
|
started: 0,
|
|
reason: `Outside update window (${config.windowStart}-${config.windowEnd})`,
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await new DrugReferenceService().attemptAutoUpdate()
|
|
return { started: result.started ? 1 : 0, reason: `drug dataset: ${result.reason}` }
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
logger.warn(`[ContentAutoUpdateService] Drug dataset freshness check failed: ${message}`)
|
|
return { started: 0, reason: `drug dataset check failed: ${message}` }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluate what the next run *would* do, without hitting the network,
|
|
* persisting state, or dispatching anything. Operates on the available-update
|
|
* state already persisted by the last check (manual or auto), so run a "Check
|
|
* for Content Updates" first if you want fresh catalog data. Used by the
|
|
* `content-auto-update:dry-run` command.
|
|
*/
|
|
async dryRun(
|
|
overrides: {
|
|
now?: DateTime
|
|
forceEnabled?: boolean
|
|
cooloffHours?: number
|
|
windowStart?: string
|
|
windowEnd?: string
|
|
maxBytesPerWindow?: number
|
|
windowBytesUsed?: number
|
|
} = {}
|
|
): Promise<{
|
|
enabled: boolean
|
|
withinWindow: boolean
|
|
config: ContentAutoUpdateConfig
|
|
eligibleCount: number
|
|
selection: ContentSelection
|
|
}> {
|
|
const base = await this.getConfig()
|
|
const config: ContentAutoUpdateConfig = {
|
|
enabled: overrides.forceEnabled ? true : base.enabled,
|
|
windowStart: overrides.windowStart ?? base.windowStart,
|
|
windowEnd: overrides.windowEnd ?? base.windowEnd,
|
|
cooloffHours: overrides.cooloffHours ?? base.cooloffHours,
|
|
maxBytesPerWindow: overrides.maxBytesPerWindow ?? base.maxBytesPerWindow,
|
|
}
|
|
const now = overrides.now ?? DateTime.now()
|
|
const withinWindow = isWithinWindow(config.windowStart, config.windowEnd, now)
|
|
|
|
const pending = await InstalledResource.query()
|
|
.whereNotNull('available_update_version')
|
|
.whereNot('resource_type', 'dataset')
|
|
const eligible = pending.filter(
|
|
(r) => this.resourceEligibility(r, config.cooloffHours, now).eligible
|
|
)
|
|
const candidates: ContentCandidate[] = eligible.map((r) => ({
|
|
resource: r,
|
|
version: r.available_update_version!,
|
|
download_url: '(dry-run)',
|
|
size_bytes: r.available_update_size_bytes ?? 0,
|
|
installed_at: r.installed_at,
|
|
}))
|
|
|
|
const usedBytes = overrides.windowBytesUsed ?? (await this.getWindowBytesUsed())
|
|
const selection = this.selectUnderCap(candidates, config.maxBytesPerWindow, usedBytes)
|
|
|
|
return {
|
|
enabled: config.enabled,
|
|
withinWindow,
|
|
config,
|
|
eligibleCount: eligible.length,
|
|
selection,
|
|
}
|
|
}
|
|
|
|
// ── Per-window budget ─────────────────────────────────────────────────────────
|
|
|
|
/** Most-recent window-open boundary as an absolute timestamp (handles wrap). */
|
|
windowStartBoundary(windowStart: string, now: DateTime): DateTime {
|
|
const minutes = parseWindowMinutes(windowStart) ?? 0
|
|
const todayStart = now.startOf('day').plus({ minutes })
|
|
return now >= todayStart ? todayStart : todayStart.minus({ days: 1 })
|
|
}
|
|
|
|
/** Reset the window budget exactly once per entry into the window. */
|
|
private async maybeResetWindowBudget(
|
|
config: ContentAutoUpdateConfig,
|
|
now: DateTime
|
|
): Promise<void> {
|
|
const boundary = this.windowStartBoundary(config.windowStart, now)
|
|
const resetAtRaw = await KVStore.getValue('contentAutoUpdate.windowResetAt')
|
|
const resetAt = resetAtRaw ? DateTime.fromISO(resetAtRaw) : null
|
|
if (!resetAt || !resetAt.isValid || resetAt < boundary) {
|
|
await KVStore.setValue('contentAutoUpdate.windowBytesUsed', '0')
|
|
await KVStore.setValue('contentAutoUpdate.windowResetAt', now.toISO()!)
|
|
}
|
|
}
|
|
|
|
private async getWindowBytesUsed(): Promise<number> {
|
|
const raw = await KVStore.getValue('contentAutoUpdate.windowBytesUsed')
|
|
const num = Number(raw)
|
|
return Number.isFinite(num) && num > 0 ? num : 0
|
|
}
|
|
|
|
private async addWindowBytesUsed(bytes: number): Promise<void> {
|
|
const used = await this.getWindowBytesUsed()
|
|
await KVStore.setValue('contentAutoUpdate.windowBytesUsed', String(used + bytes))
|
|
}
|
|
|
|
// ── Backoff + run recording ───────────────────────────────────────────────────
|
|
// Per-resource backoff lives in ../utils/content_auto_update_backoff.ts so the
|
|
// job-completion path and the worker `failed` handler can share it without an
|
|
// import cycle. The feature-level backoff below stays here.
|
|
|
|
/** Clear the feature-level backoff after a clean run. */
|
|
private async recordFeatureSuccess(): Promise<void> {
|
|
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', '0')
|
|
await KVStore.clearValue('contentAutoUpdate.lastError')
|
|
}
|
|
|
|
/** Record a whole-feature failure and self-disable the feature at the threshold. */
|
|
private async recordFeatureFailure(reason: string): Promise<void> {
|
|
const raw = await KVStore.getValue('contentAutoUpdate.consecutiveFailures')
|
|
const failures = (Number(raw) || 0) + 1
|
|
await KVStore.setValue('contentAutoUpdate.consecutiveFailures', String(failures))
|
|
await KVStore.setValue('contentAutoUpdate.lastError', reason)
|
|
if (failures >= MAX_FEATURE_FAILURES) {
|
|
await KVStore.setValue('contentAutoUpdate.enabled', false)
|
|
await KVStore.setValue(
|
|
'contentAutoUpdate.autoDisabledReason',
|
|
`Content auto-update disabled after ${failures} consecutive failures. Last error: ${reason}`
|
|
)
|
|
logger.error(
|
|
`[ContentAutoUpdateService] Feature auto-disabled after ${failures} consecutive failures`
|
|
)
|
|
}
|
|
}
|
|
|
|
private async recordRun(reason: string): Promise<void> {
|
|
await KVStore.setValue('contentAutoUpdate.lastAttemptAt', DateTime.now().toISO()!)
|
|
await KVStore.setValue('contentAutoUpdate.lastResult', reason)
|
|
}
|
|
|
|
// ── Status snapshot ───────────────────────────────────────────────────────────
|
|
|
|
/** Full state snapshot for the settings UI (resources with pending updates). */
|
|
async getStatus(): Promise<ContentAutoUpdateStatus> {
|
|
const config = await this.getConfig()
|
|
const now = DateTime.now()
|
|
|
|
const pending = await InstalledResource.query()
|
|
.whereNotNull('available_update_version')
|
|
.whereNot('resource_type', 'dataset')
|
|
const resources: ContentAutoUpdateResourceStatus[] = pending.map((resource) => {
|
|
const verdict = this.resourceEligibility(resource, config.cooloffHours, now)
|
|
const size = resource.available_update_size_bytes ?? null
|
|
const exceedsCap =
|
|
config.maxBytesPerWindow > 0 && size !== null && size > config.maxBytesPerWindow
|
|
return {
|
|
resource_id: resource.resource_id,
|
|
// `dataset` rows are filtered out of `pending` above, so ZIM/map.
|
|
resource_type: resource.resource_type as 'zim' | 'map',
|
|
current_version: resource.version,
|
|
available_update_version: resource.available_update_version,
|
|
size_bytes: size,
|
|
eligible: verdict.eligible && !exceedsCap,
|
|
reason: exceedsCap ? 'Exceeds data cap — update manually' : verdict.reason,
|
|
cooloff_remaining_hours: verdict.cooloffRemainingHours,
|
|
exceeds_cap: exceedsCap,
|
|
consecutive_failures: resource.auto_update_consecutive_failures || 0,
|
|
auto_disabled_reason: resource.auto_update_disabled_reason,
|
|
}
|
|
})
|
|
|
|
const [lastAttemptAt, lastResult, lastError, autoDisabledReason, windowBytesUsed] =
|
|
await Promise.all([
|
|
KVStore.getValue('contentAutoUpdate.lastAttemptAt'),
|
|
KVStore.getValue('contentAutoUpdate.lastResult'),
|
|
KVStore.getValue('contentAutoUpdate.lastError'),
|
|
KVStore.getValue('contentAutoUpdate.autoDisabledReason'),
|
|
this.getWindowBytesUsed(),
|
|
])
|
|
|
|
return {
|
|
...config,
|
|
withinWindow: isWithinWindow(config.windowStart, config.windowEnd, now),
|
|
windowBytesUsed,
|
|
lastAttemptAt: lastAttemptAt || null,
|
|
lastResult: lastResult || null,
|
|
lastError: lastError || null,
|
|
autoDisabledReason: autoDisabledReason || null,
|
|
resources,
|
|
}
|
|
}
|
|
}
|