120 lines
5.8 KiB
TypeScript
120 lines
5.8 KiB
TypeScript
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||
|
||
/**
|
||
* Drug Reference v1 — openFDA drug label catalog.
|
||
*
|
||
* ~259k rows of FDA drug labels (Rx + OTC), downloaded from the openFDA bulk
|
||
* export and streamed into MySQL in 500-row batches. The idempotent upsert key
|
||
* is `set_id` — a stable GUID for a labeling across all revisions. Re-running
|
||
* 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) 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
|
||
* migration. The search service degrades gracefully to LIKE when FULLTEXT is
|
||
* unavailable.
|
||
*/
|
||
export default class extends BaseSchema {
|
||
protected tableName = 'drug_labels'
|
||
|
||
async up() {
|
||
this.schema.createTable(this.tableName, (table) => {
|
||
table.bigIncrements('id').primary()
|
||
|
||
// Idempotent upsert key. UNIQUE enforced in DB so re-ingest never dupes.
|
||
table.string('set_id', 64).notNullable().unique('uniq_drug_labels_set_id')
|
||
|
||
// Per-revision GUID for provenance (not the upsert key).
|
||
table.string('spl_id', 64).nullable()
|
||
|
||
table.string('version', 16).nullable()
|
||
|
||
// Identity fields — sourced from openfda sub-object.
|
||
table.string('brand_name', 255).nullable()
|
||
table.string('generic_name', 512).nullable()
|
||
table.string('manufacturer', 512).nullable()
|
||
table.string('product_ndc', 255).nullable()
|
||
table.string('route', 255).nullable()
|
||
|
||
// OTC vs Rx discriminator. Expected: 'HUMAN OTC DRUG' | 'HUMAN PRESCRIPTION DRUG'.
|
||
// Plain varchar — not a native enum — to allow future product type additions
|
||
// without ALTER TABLE (stl_files convention).
|
||
table.string('product_type', 32).nullable()
|
||
|
||
// Normalized brand+generic blob — computed once at ingest, never on read.
|
||
// 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
|
||
// in full (indications/dosage/warnings can be multiple pages of text).
|
||
table.specificType('indications', 'mediumtext').nullable()
|
||
table.specificType('dosage', 'mediumtext').nullable()
|
||
table.specificType('warnings', 'mediumtext').nullable()
|
||
table.specificType('boxed_warning', 'mediumtext').nullable()
|
||
table.specificType('drug_interactions', 'mediumtext').nullable()
|
||
table.specificType('contraindications', 'mediumtext').nullable()
|
||
// when_using / stop_use are OTC-specific and typically shorter.
|
||
table.text('when_using').nullable()
|
||
table.text('stop_use').nullable()
|
||
|
||
// Label version date, parsed from effective_time (YYYYMMDD → 'YYYY-MM-DD').
|
||
// Stored as a fixed-width varchar, not a DATE column, so it round-trips as a
|
||
// plain string: the model declares it string|null, but mysql2 hands back a
|
||
// JS Date for a DATE column. v1 never range-queries this field, and
|
||
// 'YYYY-MM-DD' sorts chronologically as text.
|
||
table.string('source_updated_at', 10).nullable()
|
||
|
||
// Set on every upsert pass — tracks when this row was last refreshed.
|
||
table.timestamp('ingested_at').notNullable()
|
||
|
||
// ── Non-FULLTEXT indexes ────────────────────────────────────────────────
|
||
|
||
// OTC vs Rx filter pill.
|
||
table.index('product_type', 'idx_drug_labels_product_type')
|
||
// LIKE fallback + alpha sort on the brand name column.
|
||
table.index('brand_name', 'idx_drug_labels_brand')
|
||
// LIKE fallback on the normalized search blob.
|
||
table.index('searchable_name', 'idx_drug_labels_searchable_name')
|
||
})
|
||
|
||
// ── FULLTEXT index — guarded so a non-InnoDB engine doesn't block migration ──
|
||
//
|
||
// MySQL 8.0 InnoDB supports FULLTEXT natively (confirmed: repo uses mysql:8.0).
|
||
// The guard ensures a future engine change or a fresh install on an engine
|
||
// without FULLTEXT doesn't brick the migration runner — the search service
|
||
// degrades to LIKE on a MATCH() failure, so an absent index is non-fatal.
|
||
//
|
||
// Only the name index ships in v1; search MATCHes searchable_name. A combined
|
||
// 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.
|
||
//
|
||
// 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() {
|
||
this.schema.dropTable(this.tableName)
|
||
}
|
||
}
|