-
Notifications
You must be signed in to change notification settings - Fork 1
Data Model
This page is a map of the relational schema: the 47 entities grouped by area, the conventions they all follow, and the design rules that keep the model typed rather than generic. It is for anyone reading packages/database/prisma/schema.prisma or adding a table to it.
The schema is the single source of truth. Where this page and the schema disagree, the schema is right.
| Path | Contents |
|---|---|
packages/database/prisma/schema.prisma |
The whole model. 47 models, 59 enums. |
packages/database/prisma/migrations/ |
Two migrations. See Upgrades and migrations. |
packages/database/src/enums.ts |
Every enum mirrored as a literal tuple, with a compile-time parity proof. |
packages/database/src/schemas/ |
The Zod write contract per aggregate. Pure Zod, imports nothing generated. |
packages/database/src/tenant.ts |
The tenant scoping client extension. |
packages/database/src/audit.ts |
The audit hash chain. |
packages/database/src/forms.ts |
The form promotion rule. |
packages/database/src/seed/ |
A deterministic synthetic demo practice. |
The package is deliberately storage only. FHIR serialisation is @openrunic/fhir's job.
erDiagram
Organisation ||--o{ Facility : "has"
Organisation ||--o{ User : "has"
Organisation ||--o{ Patient : "has"
Patient ||--o{ Coverage : "insured by"
Payer ||--o{ Coverage : "issues"
Patient ||--o{ Appointment : "books"
Facility ||--o{ Appointment : "hosts"
User ||--o{ Appointment : "attends"
Appointment ||--o| Encounter : "becomes"
Patient ||--o{ Encounter : "has"
Encounter ||--o{ ClinicalNote : "documents"
ClinicalNote ||--o{ NoteAddendum : "corrected by"
Encounter ||--o{ Condition : "diagnoses"
Encounter ||--o{ ServiceRequest : "orders"
ServiceRequest ||--o{ DiagnosticReport : "resulted by"
DiagnosticReport ||--o{ ResultObservation : "contains"
Encounter ||--o{ ChargeItem : "captures"
ChargeItem ||--o{ ClaimLine : "billed as"
Claim ||--o{ ClaimLine : "contains"
Claim ||--o{ ClaimStatusHistory : "tracked by"
Remittance ||--o{ RemittanceLine : "contains"
Payment ||--o{ PaymentAllocation : "applied by"
Organisation ||--o{ AuditEvent : "records"
| Model | What it represents |
|---|---|
Organisation |
The tenant root. The only model without a tenantId, because its id is the tenant id. Carries slug, mode (SaaS or self host), status, timezone, and a per-tenant flags JSON object. Deleting it cascades the whole tenant. |
Facility |
A place of service. Carries code, npi, posCode (the CMS place-of-service code), timezone, and address. Scheduling, encounters, and charges are all facility-scoped. |
| Model | What it represents |
|---|---|
User |
Staff and clinicians. There is no separate practitioner table: a practitioner is a User with isProvider true, carrying npi, dea, credential, and a NUCC taxonomyCode. |
UserFacility |
Which facilities a user may work in. The policy layer intersects these with role permissions. |
Role |
A named permission bundle. System roles are seeded per tenant, so a tenant can fork them. |
Permission |
One atomic capability such as patient.read. The catalogue is data rather than code, so a plugin can register its own. |
RolePermission |
Join table between the two. |
RoleAssignment |
Binds a user to a role, optionally narrowed to one facility. A null facilityId means org-wide. |
| Model | What it represents |
|---|---|
Patient |
A slim patient core: MRN, name parts and preferred name, birth date, sex at birth and gender identity code, pronouns, race and ethnicity code arrays, language, primary contact and address, sensitivity class, portal flag. |
PatientIdentifier |
Every identifier that is not the MRN: driver's licence, payer member id, an external system's id. Carries use, system, value, and a validity period. |
RelatedPerson |
Guardians, emergency contacts, and portal proxies, with the relationship code and the three role flags that matter operationally. |
Payer |
An insurance company, with the identifiers X12 needs: x12PayerId, claimFilingCode, eligibilityPayerId. |
Coverage |
One policy in one coordination-of-benefits slot. Carries rank (primary, secondary, tertiary), memberId, groupNumber, subscriber relationship, copay and deductible in cents, and acceptAssignment. |
| Model | What it represents |
|---|---|
Appointment |
A booked slot. Appointment type is carried inline as typeCode plus typeDisplay, so renaming a type never rewrites history. Carries recurrence group and rule, createdVia, room, and checkedInAt. Its indexes are named for the two screens that read it: the day view and the flow board. |
AppointmentStatusHistory |
An append-only status trail. Wait timers derive from it, so there is no separate timing table. |
| Model | What it represents |
|---|---|
Encounter |
A visit, created automatically on check-in from the appointment. Holds a one-to-one link back to that appointment, plus class, status, reason, and signature stamps. |
ClinicalNote |
A block-structured clinical document. blocks is a JSONB block list, not HTML and not a serialised template. |
NoteAddendum |
A signed note is immutable. Later corrections are addenda carrying their own blocks, reason, and signature. |
Condition |
Problem-list entries and encounter diagnoses, split by category. Codes default to ICD-10-CM with an optional SNOMED CT code alongside. |
MedicationStatement |
What the patient reports taking, including prescriptions written elsewhere. |
MedicationRequest |
A prescription. sig is structured JSON from the sig builder, with the rendered sigText stored alongside. Carries quantity as a decimal, refills, days supply, dispense-as-written, controlled schedule, and the pharmacy's NCPDP id. |
AllergyIntolerance |
Type, category, criticality, clinical status, substance code, reaction codes, and severity. |
Immunization |
CVX, MVX, and NDC codes, lot number, dose quantity, administration time, VIS date, refusal reason, and registry reporting time. |
Observation |
Vitals, in-house labs, social-history and SDOH answers, survey scores. Typed value columns rather than one text blob. Carries a formSubmissionId when the value was promoted out of a form. |
ConsentGrant |
Recorded consent or authorization, scoped by scope and tracked through status, with revocation time and reason. |
Discrete results from an outside lab go to ResultObservation rather than Observation, so a report's lines can never orphan from their report.
| Model | What it represents |
|---|---|
ServiceRequest |
An order. Labs, imaging, procedures, referrals, and therapy are one table split by category. Carries priority, intent, ask-at-order-entry answers, requisition number, and an opaque adapter reference. |
Specimen |
A collected specimen: accession number, type, collection method, collector, volume, and rejection reason. |
DiagnosticReport |
A result report. Carries the abnormal flag, a narrative, a storage key for the original message or PDF, and the reviewer stamp. Its index on reviewer and abnormal flag is the sign-off queue. |
ResultObservation |
One discrete analyte inside a report, sequenced and uniquely keyed within it. |
Document |
Any stored binary: uploads, scans, inbound faxes, generated PDFs. Bytes never live in Postgres; the row carries a storage key, content type, SHA-256, and size. |
Task |
The typed inbox. One work engine, where the streams a user sees are filters on type rather than separate systems. Its unique constraint on the source event makes routing idempotent under at-least-once delivery. |
MessageThread |
One threading model for both portal messaging and staff chat, split by kind. |
Message |
A message in a thread, with a sender type that covers users, patients, related persons, and the system. |
| Model | What it represents |
|---|---|
ChargeItem |
A fee-sheet line. Codes default to CPT. diagnosisPointers are 1-based indices into the claim's diagnosis list, matching the 837P service-line field. Money is integer cents. |
Claim |
The claim lifecycle spine. Carries status, frequency, the diagnosis code list, four money totals, self-relations for secondary claims and corrections, control numbers, and a snapshot of the as-built payload so a resubmission is reproducible even after the encounter is edited. |
ClaimLine |
A service line, sequenced within the claim, with a foreign key back to the charge it came from. |
ClaimStatusHistory |
Every claim transition, tagged with its source. Acknowledgements, status responses, and remittances all write here, which is why there is no separate transaction-history screen. |
Payment |
Money received, from a patient or a payer. Card data is never stored; only an opaque gateway reference. |
PaymentAllocation |
Applies part of a payment to a claim, line, or charge, carrying the X12 adjustment group and reason codes. A patient's balance is charges minus allocations, one ledger with no second source of truth. |
Remittance |
An electronic remittance advice. Parsing produces lines; posting produces payments and allocations. |
RemittanceLine |
One service line within a remittance, with the payer control number used for matching, the money breakdown, adjustment codes, remark codes, and a matched flag. Unmatched lines become exception tasks rather than being dropped. |
Statement |
A patient statement, its dunning cycle, its generated PDF key, and a single-use pay-link token that rotates on every regeneration. |
FormDefinition, FormSubmission, and FormPromotedValue. See Form engine.
| Model | What it represents |
|---|---|
TerminologyCode |
A bring-your-own terminology cache. openrunic ships no code content, because the major code systems carry their own licences. system is a string rather than an enum, so a new system loads without a migration. |
AuditEvent |
The append-only audit stream. See Audit and hash chain. |
There are 59 enums. Each is mirrored in packages/database/src/enums.ts as a plain literal tuple, so the Zod schemas run without a generated Prisma client. That file ends with a type-only parity proof: adding a value to an enum without updating its tuple fails pnpm --filter @openrunic/database type-check rather than failing at runtime.
These are stated in the schema header and hold across the whole model.
Identifiers. UUIDv7, minted in application code, with no database default. No serial columns, no dual keying, no separate public-id overlay. packages/database/src/uuid.ts provides uuidv7, isUuidv7, and uuidv7Timestamp.
Tenant prefixing. Every composite index and every unique constraint is tenant-prefixed, with documented exceptions for child-scoped uniques that reach the tenant through their parent. See Multi-tenancy and isolation.
Enums versus coded strings. A Prisma enum is used only where the value set is closed and owned by the project or by a frozen HL7 or FHIR value set. Anything from a licensed or evolving terminology (ICD-10-CM, CPT, HCPCS, LOINC, SNOMED CT, RxNorm, CVX, NDC, UCUM, and the X12 adjustment and remark code sets) is a plain string code plus a codeSystem string, optionally resolvable against TerminologyCode. Terminology is never vendored into the repository, so it must never be baked into the schema.
Relations versus provenance stamps. A foreign key is declared where the reference is part of the record's meaning and is queried. The who-stamped-this fields (signedById, reviewedById, postedById, publishedById, voidedById, completedById) are plain indexed UUID columns holding a user id with no declared relation, because they must survive a user being deactivated and never drive a join in a hot path.
Money and quantities. Money is integer cents in an Int column, never a float. Genuinely fractional quantities are Decimal with explicit precision, for example Decimal(12,3) for dispense quantity.
Deletion. Clinical rows are never hard-deleted. A correction is a status transition to ENTERED_IN_ERROR, VOIDED, or CANCELLED, plus an audit event.
Timestamps. createdAt and updatedAt on every model, including the append-only ones.
The schema says this outright in three places, and the reasoning is worth understanding before proposing a generic attribute table.
Patient is a slim core. Custom demographics live in a form submission bound to the patient, never in extra columns on the table and never in an attribute-value table. The primary address and contact points are inline because every screen needs them.
FormSubmission.values is one JSONB document validated against the pinned definition's generated schema. There is exactly one row per submission, never one row per answered field.
FormPromotedValue is a fixed table with typed columns rather than per-form dynamic DDL, because dynamic DDL would fight the expand-and-contract migration discipline. Graphing a promoted field over time is one index scan rather than a join tree.
The failure mode being avoided is a well-known one in this category of software: a customization layer that is genuinely powerful but hostile to use, configured through cryptic option strings with no preview, storing every answer as a row in a generic attribute table. Every query against such a store is a self-join, every value is a string, and nothing is typed.
packages/database/src/client.ts exports createPrismaClient, a lazy factory rather than a module-level singleton. Nothing is read from the environment and no client is constructed at import time, so importing the package never requires a configured database. Call it once per process and share the instance; each client owns a connection pool.
It uses the Prisma driver adapter for Postgres:
const adapter = new PrismaPg({ connectionString });
return new PrismaClient({ adapter, ...(options.log ? { log: options.log } : {}) });The generator block uses the prisma-client generator with ESM output into src/generated/prisma. The datasource block declares only provider = "postgresql"; the connection URL lives in packages/database/prisma.config.ts, which prefers DIRECT_URL over DATABASE_URL so migration commands bypass any pooler. The config omits the datasource key entirely when no URL is set, which keeps prisma generate and prisma validate usable with no database at all.
packages/database/src/seed/ builds a synthetic demo practice.
-
data.tsexportsbuildDemoPractice(options), which builds plain rows with no database involved. It is fully deterministic: no random source, no wall clock, and no fixture generator library. Ids come from a UUIDv7 generator wired to a fixed clock and byte source, and every clinical value derives from the patient's index, so two runs produce byte-identical rows. -
index.tsexportsseedDemoPractice(client, options), which inserts everything in one transaction across orderedcreateManysteps that follow the foreign-key graph. Either the whole practice lands or none of it does. It takes a root client on purpose, because it creates the tenant and there is therefore no tenant to scope it to yet. -
run.tsis the CLI entry, run aspnpm --filter @openrunic/database db:seed. It refuses to run whenNODE_ENVisproductionunlessOPENRUNIC_SEED_FORCEis set.
Everything in the seed is synthetic by rule. Names like "Testina Patientsson" and payers like "Placeholder Mutual Health", with identifiers drawn from reserved or non-routable ranges. The seed also builds a verifiable audit chain, and a test asserts that verifyAuditChain accepts it.
pnpm --filter @openrunic/database db:generate # prisma generate
pnpm --filter @openrunic/database db:migrate # prisma migrate dev
pnpm --filter @openrunic/database db:deploy # prisma migrate deploy
pnpm --filter @openrunic/database db:seed # node dist/seed/run.js
pnpm --filter @openrunic/database testThe test suite covers the pure modules only, with 95 percent thresholds on statements, branches, functions, and lines: src/audit.ts, src/uuid.ts, src/forms.ts, and src/schemas/. The client factory, the tenant extension factory, and the seed runner need a generated client and a real database, so they are covered by the API package's integration tests instead.
openrunic is an open-source operating system for human health. Pre-alpha: do not run it in production, and never put real patient data into it.
Repository · Licence (AGPL-3.0-only) · Security policy · Contributing · Code of conduct
Where this wiki and the repository disagree, the repository is right.