Observations
Coded measurements of any record, such as vital signs, against definitions with a unit, a code and ranges, with a computed flag, critical alerts, amendments, device ingest, series and form mapping.
observations records measurements of a record: a patient's heart rate, a
machine's temperature, a client's weight. A definition says what is measured,
in which unit and with which code, and which values are valid, normal and
critical. Each observation gets a flag from those ranges, a critical value
alerts the record's leads, and a correction keeps the original as amended.
better-supabase add observationsimport { createObservations } from "better-supabase/blocks/observations";The module requires memberships, permissions and subjects: an
observation belongs to any record type in config.subjects, and the caller
needs access to that record. With forms a submitted form
records observations, with notifications a
critical value notifies the record's leads, with outbox it records the
events, and with audit every change is audited.
Tables
| Table | Holds |
|---|---|
observation_definitions | A definition per organization by key, or a system one, with its value type, unit and ranges |
observations | The measurements: a record, a definition, the value, its flag, status and source |
Members read their organizations' definitions and the system ones. Reading an observation also needs access to its record, and the rest needs a permission:
| Permission | Allows |
|---|---|
observations.read | reading observations |
observations.record | recording, amending and marking observations in error |
observations.manage | saving and archiving the organization's definitions |
Admins have all three; members get read and record.
Definitions
Set options.defaults to vitals to install nine system definitions every
organization reads, each with its LOINC code and UCUM unit:
blocks: {
observations: { options: { defaults: "vitals" } },
},| Key | Title | Unit | Reference range |
|---|---|---|---|
heart_rate | Heart rate | /min | 51 to 90 |
blood_pressure | Blood pressure | mm[Hg], systolic, diastolic | 100 to 140, 60 to 90 |
body_temperature | Body temperature | Cel | 36.1 to 38 |
respiratory_rate | Respiratory rate | /min | 12 to 20 |
oxygen_saturation | Oxygen saturation | % | 96 and up |
body_weight | Body weight | kg | none |
body_height | Body height | cm | none |
pain_score | Pain score | {score} | up to 3 |
blood_glucose | Blood glucose | mmol/L | 4 to 7.8 |
An organization adds its own, such as an early warning score:
await db.observations
.saveDefinition(organizationId, "mews", {
title: "Modified early warning score",
valueType: "integer",
unit: "{score}",
validLow: 0,
validHigh: 14,
referenceHigh: 2,
criticalHigh: 5,
})
.orThrow();saveDefinition creates the definition or updates the one with the same key,
and restores it if it was archived. A key a system definition uses overrides
it for that organization. archiveDefinition stops a definition from taking
new observations; its observations stay. definitions lists the
organization's and the system ones by title, with
{ includeArchived: true } for the archived ones too.
valueType | Value |
|---|---|
number | a number, rounded to decimals |
integer | a whole number |
boolean | true or false |
code | a code from your own list, as a string |
text | free text, up to 4,000 characters |
components | the component values by key, such as { systolic: 120, diastolic: 80 } |
A components definition has one to twenty components, each with a key,
and optionally a title, code, unit, decimals, its own ranges and
optional: true. A value needs every component that isn't optional.
Ranges and flags
A value outside the valid range is refused with OBSERVATION_VALUE_INVALID.
Inside it, the flag is critical_low or critical_high at or past a critical
bound, low or high outside the reference range, and normal otherwise. A
definition without ranges leaves the flag out. A components value takes the
furthest flag of its parts.
Recording
const reading = await db.observations
.record(organizationId, {
subjectType: "patient",
subjectId: patientId,
definitionKey: "heart_rate",
value: 135,
method: "palpation",
})
.orThrow();
// reading.flag is "critical_high"observedAt defaults to now and may not be in the future. recordMany
records up to 500 observations, all or none. A critical value records
observation.critical_recorded and, with notifications installed, notifies each member
of the record's team with the role lead, except the caller.
ingest takes readings from a device or an import with the service role.
Each item carries an externalId; an item ingested before is skipped, so a
retried batch records nothing twice:
const result = await observations
.ingest(organizationId, [
{
subjectType: "patient",
subjectId: patientId,
definitionKey: "oxygen_saturation",
value: 94,
observedAt: Temporal.Instant.from("2026-10-30T08:00:00Z"),
externalId: "oximeter-17:1730275200",
device: "oximeter-17",
},
])
.orThrow();
// { inserted: 1, skipped: 0, ids: ["…"] }Corrections
const corrected = await db.observations
.amend(reading.id, { value: 115 }, "Counted over 15 seconds")
.orThrow();
// corrected.replacesId is reading.id, reading's status is now "amended"amend records the corrected observation and marks the original amended;
what the changes leave out stays. markError(observationId, reason) marks one
entered_in_error, and marking twice keeps the first reason. Neither deletes
anything.
Reading
const latest = await db.observations.latest("patient", patientId).orThrow();
// { heart_rate: { value: 115, flag: "high", … }, blood_pressure: { … } }
const points = await db.observations
.series("patient", patientId, "heart_rate", {
since: Temporal.Now.instant().subtract({ hours: 24 }),
})
.orThrow();
// [{ id, observedAt, value, flag }], oldest first
const daily = await db.observations
.buckets("patient", patientId, "blood_pressure", "day")
.orThrow();
// [{ bucket, count, min, max, avg, components: { systolic: { min, max, avg } } }]list returns a record's observations, the latest first, filtered by
definitionKey; it leaves out amended and entered-in-error ones unless you
pass includeAll: true. limit defaults to 100, at most 1,000. series,
buckets and latest only read final values. buckets groups by UTC hour,
day, week or month, with until excluded from the range.
The observation chart recipe renders the latest values with their flags and a sparkline of each series.
From a form
With forms installed, a form field maps to a definition
through meta.observation, and to one component through meta.component:
fields: [
{ key: "pulse", label: "Pulse", type: "number", meta: { observation: "heart_rate" } },
{ key: "sys", label: "Systolic", type: "number", meta: { observation: "blood_pressure", component: "systolic" } },
{ key: "dia", label: "Diastolic", type: "number", meta: { observation: "blood_pressure", component: "diastolic" } },
],A submission about a record (submit_form with a subject) records its
observations with source: "form" and the submissionId, in the same
transaction. fromSubmission(submissionId) records them for a submission made
before the mapping, and a submission is recorded only once.
FHIR
The columns follow the FHIR R4 Observation resource, so an export maps one to
one: the definition's codeSystem and code are Observation.code, the unit
is a UCUM code, components are Observation.component, the flag is the
interpretation (N, L, H, LL, HH), and final, amended and
entered_in_error are FHIR statuses.
Options
| Option | Default | Does |
|---|---|---|
defaults | none | vitals installs the nine system definitions above |
partitioned | false | Partitions the observations table by month; amendments keep their link without a foreign key |
extraColumns | none | Extra columns on the observations table |
Pass a Standard Schema as fields to type the extra columns, parse them on
reads and validate them on record:
const observations = createObservations({
transport,
fields: z.object({ ward: z.string().optional() }),
});Adopting tables
An app with its own tables keeps them: the module runs over them and maps
their columns. The definition's timestamps are optional; map them to null
and the module leaves them out.
blocks: {
observations: {
tables: {
definitions: "public.measurement_types",
observations: "public.measurements",
},
columns: {
definitions: { title: "name", createdAt: null, updatedAt: null },
},
},
},Events
| Event | Data |
|---|---|
observation_definition.created, observation_definition.updated | organizationId (null for a system one), definitionId, key, title |
observation.recorded | organizationId, observationId, definitionKey, subjectType, subjectId, observedAt, flag, source |
observation.critical_recorded | the same observation fields, with observedAt and flag |
observation.amended | the same observation fields, with replacesId and reason |
observation.error_marked | the same observation fields, with reason |
Hooks
before_observation_definition_save, after_observation_definition_save,
before_observation_record, after_observation_record,
before_observation_amend, after_observation_amend,
before_observation_mark_error and after_observation_mark_error run in the
same transaction as the change. See extending blocks.
Errors
| Hint | Cause |
|---|---|
OBSERVATION_FORBIDDEN | the caller lacks the permission or can't access the record |
OBSERVATION_DEFINITION_INVALID | a bad key, title, value type, unit, components or ranges |
OBSERVATION_DEFINITION_NOT_FOUND | no definition with that key, or a form field names one that doesn't exist |
OBSERVATION_DEFINITION_ARCHIVED | the definition is archived |
OBSERVATION_VALUE_INVALID | a value of the wrong type, outside the valid range, or missing a component |
OBSERVATION_INVALID | a bad record, a future observedAt, too many items, or a bad filter |
OBSERVATION_NOT_FOUND | no observation or form submission the caller can change |
ACTOR_READ_ONLY | a read-only support session tried a write |
Without the client
db.observations is this client. Code that does not go through bsb.db()
builds it with a transport:
import { createObservations } from "better-supabase/blocks/observations";
const observations = createObservations({ transport });
const latest = await observations.latest("patient", patientId).orThrow();Last updated on
Qualifications
Certifications, licences and skills that members, companies and assets hold, with a validity, evidence, requirements, a gap report, the holders qualified on a date and expiry notifications.
Leave
Leave types in days or hours, requests approved through the approvals module, and balances on the ledger block per member and year with grants, accruals, adjustments and a year-end carryover.