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
This commit is contained in:
Chris 2026-06-23 07:04:57 -05:00
parent 5696c14bbe
commit cda8f740af
15 changed files with 49 additions and 38 deletions

View File

@ -49,6 +49,7 @@ export default class DrugReferenceController {
ingestStatus: null,
rowCount: 0,
conditions: [],
remedies: [],
})
}
}

View File

@ -1,9 +1,9 @@
/**
* "When to use what" curated condition spine (Phase 1, runtime source of truth).
*
* A bounded, hand-curated list of common first-aid / emergency situations the
* "small booklet" the upstream #664 requester described. Each entry's
* `searchTerms` drive the FULLTEXT search over `drug_labels.indications`.
* A bounded, hand-curated list of common first-aid / emergency situations a
* "small booklet" of when-to-use-what. Each entry's `searchTerms` drive the
* FULLTEXT search over `drug_labels.indications`.
*
* WHY a TS constant (not a JSON file read at runtime):
* The repo-root `collections/conditions.json` mirror exists for parity with
@ -12,7 +12,7 @@
* image (`ADD admin/ ./`) and ships only the compiled `build/` output, so a
* repo-root JSON file never reaches the container filesystem. Bundling the
* spine as a compiled module guarantees it is always present at runtime with
* no path-resolution fragility. The spec explicitly allows "JSON/TS constant".
* no path-resolution fragility.
*
* Keep this file and `collections/conditions.json` in sync (same `version`,
* same entries) the JSON is the public/browseable copy, this is the canonical

View File

@ -6,7 +6,7 @@
* is what the running app reads (the image ships only compiled admin/build/).
* Keep the two in sync. Sources are US-government public-domain pages (CDC,
* NIH/NLM MedlinePlus summaries, FDA consumer updates) each entry carries its
* exact sourceUrl. See GitHub issue #23.
* exact sourceUrl.
*/
import type { NaturalRemediesFile } from '../../types/conditions.js'

View File

@ -13,7 +13,7 @@
* into the image (`ADD admin/ ./`) and ships only the compiled `build/` output,
* so a repo-root JSON file never reaches the container filesystem. Bundling the
* remedies as a compiled module guarantees they are always present at runtime
* with no path-resolution fragility. The spec explicitly allows "JSON/TS constant".
* with no path-resolution fragility.
*
* Keep this file and `collections/natural_remedies.json` in sync same `version`,
* same remedy slugs, same remedy count. `parseNaturalRemediesFile` (admin/util/conditions.ts)

View File

@ -41,9 +41,9 @@ import type { DrugSearchResult } from '../../types/drug_reference.js'
/**
* Module-level merged remedies corpus (fail-soft): the NCCIH herbs plus the
* non-herbal home-care measures (CDC/NIH/FDA, issue #23), each entry tagged
* with its kind so the UI can badge them apart. Slugs are disjoint between the
* two files (validated at curation time).
* non-herbal home-care measures (CDC/NIH/FDA), each entry tagged with its kind
* so the UI can badge them apart. Slugs are disjoint between the two files
* (validated at curation time).
*/
const HERB_FILE = parseNaturalRemediesFile(NATURAL_REMEDIES_FILE)
const HOME_FILE = parseNaturalRemediesFile(HOME_REMEDIES_FILE)

View File

@ -9,12 +9,13 @@ import { BaseSchema } from '@adonisjs/lucid/schema'
* the ingest refreshes existing rows in place; no manual purge needed.
*
* Section text columns use mediumtext (up to 16 MB) so no openFDA section is
* truncated. The `searchable_name` varchar(768) stays within InnoDB's index
* key-length budget under utf8mb4 (191 chars × 4 bytes = 764 < 767 limit for
* a single column; 768 bytes here fits because MySQL counts bytes for the
* key-length limit when the column is declared as varchar, not character count
* for the prefix approach). If a tighter budget is needed in a utf8mb4_bin
* collation, use varchar(191) but the utf8mb4 default collation is fine.
* truncated. The `searchable_name` varchar(768) is indexed (idx_drug_labels_searchable_name).
* Under utf8mb4 that column is 768 × 4 = 3072 bytes, which is exactly the InnoDB
* index-prefix limit for the DYNAMIC / COMPRESSED row formats (DYNAMIC is the
* MySQL 8.0 default). It would overflow the 767-byte limit of the older
* REDUNDANT / COMPACT row formats, so this depends on the 8.0 default row format.
* If a deployment forces an older row format, drop the column to varchar(191)
* (191 × 4 = 764 767) to stay within the 767-byte budget.
*
* The FULLTEXT index is created in a guarded try/catch so a non-InnoDB engine
* or an older MySQL version that doesn't support FULLTEXT doesn't break the
@ -49,7 +50,8 @@ export default class extends BaseSchema {
table.string('product_type', 32).nullable()
// Normalized brand+generic blob — computed once at ingest, never on read.
// 768 chars stays within InnoDB's utf8mb4 index key-length budget.
// 768 chars × 4 bytes (utf8mb4) = 3072 = the InnoDB DYNAMIC/COMPRESSED
// index-prefix limit (see header note).
table.string('searchable_name', 768).nullable()
// Section text — mediumtext so even the longest FDA label bodies are stored
@ -95,13 +97,20 @@ export default class extends BaseSchema {
// name+indications index (search-by-what-it-treats) is deferred: FULLTEXT
// can't take a prefix length, and indexing the full mediumtext body adds heavy
// index weight v1 doesn't use.
try {
await this.db.rawQuery(
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name (searchable_name)`
)
} catch {
// Non-InnoDB or FULLTEXT unsupported — search falls back to LIKE.
}
//
// Deferred so it runs AFTER createTable executes — Lucid's schema builder
// is deferred, so a bare ALTER here would hit a not-yet-created table. The
// this.defer(db => …) pattern (see 1775100000001_create_custom_library_sources_table.ts)
// queues it to run on the live connection once the table exists.
this.defer(async (db) => {
try {
await db.rawQuery(
`ALTER TABLE drug_labels ADD FULLTEXT INDEX ft_drug_labels_name (searchable_name)`
)
} catch {
// Non-InnoDB or FULLTEXT unsupported — search falls back to LIKE.
}
})
}
async down() {

View File

@ -10,7 +10,7 @@ import { BaseSchema } from '@adonisjs/lucid/schema'
* Design notes:
* - FULLTEXT indexes cannot take a column prefix length, so the full
* mediumtext body of `indications` is indexed. On ~259k rows this adds
* meaningful index weight (tracked in issue #11).
* meaningful index weight.
* - The guard mirrors the existing ft_drug_labels_name guard in migration
* 1778600000004: a non-InnoDB engine or a MySQL version without FULLTEXT
* support must not block the migration runner. The indication-search path

View File

@ -4,9 +4,9 @@ import { IconAlertTriangle } from '@tabler/icons-react'
* "When to use what" top-of-page safety banner.
*
* A prominent amber callout that renders at the TOP of both the condition index
* and detail pages. This is a hard ship requirement (per the design spec), not a
* footnote: results are FDA label-indication matches, NOT recommendations, NOT
* an FDA endorsement, and NOT a drug-interaction checker.
* and detail pages: results are FDA label-indication matches, NOT
* recommendations, NOT an FDA endorsement, and NOT a drug-interaction checker.
* It leads the page (not a footnote) so the caveat is read before any result.
*/
export default function SafetyBanner() {
return (

View File

@ -112,7 +112,7 @@ function drugKey(d: DrugSearchResult): string {
* Once data is loaded: chips + dual-section results, with the FDA-data update control
* and source citation at the foot.
*/
export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions, remedies }: PageProps) {
export default function DrugReferenceIndex({ ingestStatus, rowCount, conditions, remedies = [] }: PageProps) {
const [query, setQuery] = useState('')
const [productType, setProductType] = useState<string | null>(null)
const [route, setRoute] = useState<string | null>(null)

View File

@ -156,7 +156,7 @@ export default function DrugReferenceShow({ label, situations = [] }: PageProps)
<LabelSection
title="Drug Interactions"
body={label.drug_interactions}
footnote="Single-drug label information — not a cross-drug interaction checker (see issue #9)"
footnote="Single-drug label information — not a cross-drug interaction checker"
/>
)}

View File

@ -8,7 +8,7 @@
* node --experimental-strip-types tests/standalone/conditions.standalone.ts
*
* Also smoke-checks the shipped curated spine (admin/app/data/conditions.ts)
* parses cleanly and stays in the 3050 range the spec calls for.
* parses cleanly and stays a bounded curated list of ~3050 conditions.
*/
import assert from 'node:assert/strict'
import {
@ -276,7 +276,7 @@ check('shipped CONDITIONS_FILE parses cleanly with no dropped entries', () => {
assert.equal(parsed.conditions.length, CONDITIONS_FILE.conditions.length)
})
check('shipped spine holds 3050 curated conditions (spec range)', () => {
check('shipped spine holds a bounded curated list of ~3050 conditions', () => {
const n = CONDITIONS_FILE.conditions.length
assert.ok(n >= 30 && n <= 50, `expected 3050 conditions, got ${n}`)
})

View File

@ -311,7 +311,7 @@ check('every remedy condition slug exists in the conditions spine', () => {
)
})
check('shipped NATURAL_REMEDIES_FILE has 18 remedies (spec count)', () => {
check('shipped NATURAL_REMEDIES_FILE has the 18 curated remedies', () => {
const n = NATURAL_REMEDIES_FILE.remedies.length
assert.equal(n, 18, `expected 18 remedies, got ${n}`)
})

View File

@ -4,5 +4,5 @@
"rootDir": "./",
"outDir": "./build"
},
"exclude": ["./inertia/**/*", "node_modules", "build"]
"exclude": ["./inertia/**/*", "tests/standalone/**/*", "node_modules", "build"]
}

View File

@ -4,7 +4,7 @@
* A condition (situation) is a curated first-aid / emergency scenario the user
* browses or searches. Each carries `searchTerms` (synonyms) that drive the
* FULLTEXT search over `drug_labels.indications` the same machinery the
* Drug Reference indication search (#11) uses.
* indication FULLTEXT search uses.
*
* Phase 1 maps conditions OTC drugs.
* Phase 2 adds natural remedies from NCCIH, resolved against the same condition
@ -78,8 +78,9 @@ export interface NaturalRemedy {
/**
* Which curated corpus the entry came from. 'herb' = NCCIH herbal fact
* sheets; 'self-care' = non-herbal home-care measures from CDC/NIH/FDA pages
* (issue #23). Assigned at merge time in condition_service the JSON files
* don't carry it. Optional so older data parses; absent means 'herb'.
* (the non-herbal home-care entries). Assigned at merge time in
* condition_service the JSON files don't carry it. Optional so older data
* parses; absent means 'herb'.
*/
kind?: 'herb' | 'self-care'
}

View File

@ -165,8 +165,8 @@ export function situationsForIndications(
* searchTerms.
*
* - Multi-word terms are wrapped in double quotes so they match as a phrase
* ("sore throat") rather than as two loose tokens this tightens precision,
* the trade-off the spec calls out for relevance-vs-precision.
* ("sore throat") rather than as two loose tokens this trades a little
* recall for precision on multi-word conditions.
* - Single-word terms are passed through bare.
* - Internal double-quotes inside a term are stripped (they would break the
* phrase quoting).