@ovok/core 0.4.40 → 0.4.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import type { OvokClient, SaveObservationsResult } from "../../ovok-client";
1
+ import type { OvokClient, SaveObservationsOptions, SaveObservationsResult } from "../../ovok-client";
2
2
  import { GenerateObservationBodyParams } from "../types/generate-observation-body/GenerateObservationBodyParams";
3
3
  /** Framework-free save helper for apps that do not use React hooks. */
4
- export declare const saveMeasurement: (client: OvokClient, params: GenerateObservationBodyParams) => Promise<SaveObservationsResult>;
4
+ export declare const saveMeasurement: (client: OvokClient, params: GenerateObservationBodyParams, options?: SaveObservationsOptions) => Promise<SaveObservationsResult>;
@@ -2,5 +2,5 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.saveMeasurement = void 0;
4
4
  /** Framework-free save helper for apps that do not use React hooks. */
5
- const saveMeasurement = (client, params) => client.saveMeasurement(params);
5
+ const saveMeasurement = (client, params, options) => client.saveMeasurement(params, options);
6
6
  exports.saveMeasurement = saveMeasurement;
@@ -4,3 +4,8 @@ export declare const MAX_CONDITIONAL_OBSERVATION_ENTRIES = 8;
4
4
  export declare const linkObservationMembers: (observations: Observation[]) => Observation[];
5
5
  export declare const splitObservationBundleEntries: (bundle: Bundle) => BundleEntry[][] | undefined;
6
6
  export declare const executeObservationBundle: (bundle: Bundle, executeBatch: (bundle: Bundle) => Promise<Bundle>) => Promise<Bundle>;
7
+ /**
8
+ * Readings' entries packed into as few requests as fit `MAX_OBSERVATION_BUNDLE_BYTES`, each reading whole. A reading
9
+ * too big for one request is a request by itself, which `executeObservationBundle` splits.
10
+ */
11
+ export declare const packObservationGroups: (groups: BundleEntry[][]) => BundleEntry[][][];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.executeObservationBundle = exports.splitObservationBundleEntries = exports.linkObservationMembers = exports.MAX_CONDITIONAL_OBSERVATION_ENTRIES = exports.MAX_OBSERVATION_BUNDLE_BYTES = void 0;
3
+ exports.packObservationGroups = exports.executeObservationBundle = exports.splitObservationBundleEntries = exports.linkObservationMembers = exports.MAX_CONDITIONAL_OBSERVATION_ENTRIES = exports.MAX_OBSERVATION_BUNDLE_BYTES = void 0;
4
4
  const offline_1 = require("../offline");
5
5
  exports.MAX_OBSERVATION_BUNDLE_BYTES = 900000;
6
6
  exports.MAX_CONDITIONAL_OBSERVATION_ENTRIES = 8;
@@ -78,7 +78,7 @@ const splitObservationBundleEntries = (bundle) => {
78
78
  };
79
79
  exports.splitObservationBundleEntries = splitObservationBundleEntries;
80
80
  const executeObservationBundle = async (bundle, executeBatch) => {
81
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
81
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
82
82
  const entries = (_a = bundle.entry) !== null && _a !== void 0 ? _a : [];
83
83
  if (bundle.type === "transaction" &&
84
84
  entries.length > exports.MAX_CONDITIONAL_OBSERVATION_ENTRIES) {
@@ -129,13 +129,40 @@ const executeObservationBundle = async (bundle, executeBatch) => {
129
129
  };
130
130
  }
131
131
  const response = await executeBatch(bundle);
132
- if ((_m = response.entry) === null || _m === void 0 ? void 0 : _m.some((entry) => {
133
- var _a, _b;
134
- const status = (_b = (_a = entry.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : "";
135
- return status.startsWith("4") || status.startsWith("5");
136
- })) {
132
+ if (((_o = (_m = response.entry) === null || _m === void 0 ? void 0 : _m.length) !== null && _o !== void 0 ? _o : 0) !== entries.length ||
133
+ ((_p = response.entry) === null || _p === void 0 ? void 0 : _p.some((entry) => {
134
+ var _a, _b;
135
+ const status = (_b = (_a = entry.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : "";
136
+ return status.startsWith("4") || status.startsWith("5");
137
+ }))) {
137
138
  throw response;
138
139
  }
139
140
  return response;
140
141
  };
141
142
  exports.executeObservationBundle = executeObservationBundle;
143
+ /**
144
+ * Readings' entries packed into as few requests as fit `MAX_OBSERVATION_BUNDLE_BYTES`, each reading whole. A reading
145
+ * too big for one request is a request by itself, which `executeObservationBundle` splits.
146
+ */
147
+ const packObservationGroups = (groups) => {
148
+ const envelope = JSON.stringify({ resourceType: "Bundle", type: "batch", entry: [] }).length;
149
+ const requests = [];
150
+ let request = [];
151
+ let size = envelope;
152
+ for (const group of groups) {
153
+ // Each entry adds its JSON and a separating comma.
154
+ const groupSize = group.reduce((total, entry) => total + JSON.stringify(entry).length + 1, 0);
155
+ if (request.length > 0 && size + groupSize > exports.MAX_OBSERVATION_BUNDLE_BYTES) {
156
+ requests.push(request);
157
+ request = [];
158
+ size = envelope;
159
+ }
160
+ request.push(group);
161
+ size += groupSize;
162
+ }
163
+ if (request.length > 0) {
164
+ requests.push(request);
165
+ }
166
+ return requests;
167
+ };
168
+ exports.packObservationGroups = packObservationGroups;
@@ -0,0 +1,33 @@
1
+ import { Observation } from "@medplum/fhirtypes";
2
+ import { MeasurementDevice } from "./types/measurement/MeasurementDevice";
3
+ import { MeasurementSeries } from "./types/measurement/MeasurementSeries";
4
+ export type SeriesObservationParams = {
5
+ series: MeasurementSeries;
6
+ patientId: string;
7
+ device?: MeasurementDevice;
8
+ /** `preliminary` for values a device's own recording may still replace. */
9
+ status?: Observation["status"];
10
+ };
11
+ /** The values of a series observation, one per slot from `start`. */
12
+ export type ReadSeries = {
13
+ start: Date;
14
+ periodMs: number;
15
+ values: Array<number | null>;
16
+ };
17
+ export type SeriesSummary = {
18
+ min: number;
19
+ max: number;
20
+ mean: number;
21
+ count: number;
22
+ };
23
+ /**
24
+ * A series as observations: one per measured property, in `MEASUREMENT_FIELDS` order with the main one first, each
25
+ * holding its values as `valueSampledData` across the series' `effectivePeriod`. They carry the same codes as a
26
+ * single reading of that kind. Save them with `saveObservations`, which links the rest to the first. ECG recordings
27
+ * keep their own path, `generateObservationBodiesByMeasurement`.
28
+ */
29
+ export declare const seriesObservations: ({ series, patientId, device, status, }: SeriesObservationParams) => Observation[];
30
+ /** The values of a series observation (`valueSampledData` with one dimension), or `undefined` for any other. */
31
+ export declare const readSeries: (observation: Observation) => ReadSeries | undefined;
32
+ /** The lowest, highest and mean of a series' values, or `undefined` when it holds none. */
33
+ export declare const summarizeSeries: (values: ReadonlyArray<number | null>) => SeriesSummary | undefined;
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.summarizeSeries = exports.readSeries = exports.seriesObservations = void 0;
4
+ const creator_1 = require("./services/creator");
5
+ const getDeviceObject_1 = require("./utils/getDeviceObject");
6
+ /** FHIR's mark for a slot with no value. */
7
+ const NO_VALUE = "E";
8
+ const sampledData = (values, decimals) => values
9
+ .map((value) => (value === null || !Number.isFinite(value) ? NO_VALUE : String(Number(value.toFixed(decimals)))))
10
+ .join(" ");
11
+ /**
12
+ * A series as observations: one per measured property, in `MEASUREMENT_FIELDS` order with the main one first, each
13
+ * holding its values as `valueSampledData` across the series' `effectivePeriod`. They carry the same codes as a
14
+ * single reading of that kind. Save them with `saveObservations`, which links the rest to the first. ECG recordings
15
+ * keep their own path, `generateObservationBodiesByMeasurement`.
16
+ */
17
+ const seriesObservations = ({ series, patientId, device, status = "registered", }) => {
18
+ const service = creator_1.ObservationServiceCreator.getInstance().getService(series.measurementTypeKey);
19
+ const codes = service.getObservationCodes();
20
+ const slots = Math.max(0, ...Object.values(series.values).map((values) => { var _a; return (_a = values === null || values === void 0 ? void 0 : values.length) !== null && _a !== void 0 ? _a : 0; }));
21
+ const effectivePeriod = {
22
+ start: series.start.toISOString(),
23
+ end: new Date(series.start.getTime() + slots * series.periodMs).toISOString(),
24
+ };
25
+ return service.getFields().flatMap((field, index) => {
26
+ const values = series.values[field.key];
27
+ const code = codes[index];
28
+ if (!code || !(values === null || values === void 0 ? void 0 : values.some((value) => value !== null))) {
29
+ return [];
30
+ }
31
+ return [{
32
+ resourceType: "Observation",
33
+ status,
34
+ subject: { reference: `Patient/${patientId}` },
35
+ device: (0, getDeviceObject_1.getDeviceObject)(device),
36
+ code,
37
+ effectivePeriod,
38
+ valueSampledData: {
39
+ origin: { value: 0, unit: field.unit },
40
+ period: series.periodMs,
41
+ dimensions: 1,
42
+ data: sampledData(values, field.decimals),
43
+ },
44
+ }];
45
+ });
46
+ };
47
+ exports.seriesObservations = seriesObservations;
48
+ /** The values of a series observation (`valueSampledData` with one dimension), or `undefined` for any other. */
49
+ const readSeries = (observation) => {
50
+ var _a, _b, _c, _d, _e, _f;
51
+ const sampled = observation.valueSampledData;
52
+ const start = (_b = (_a = observation.effectivePeriod) === null || _a === void 0 ? void 0 : _a.start) !== null && _b !== void 0 ? _b : observation.effectiveDateTime;
53
+ if (!(sampled === null || sampled === void 0 ? void 0 : sampled.data) || !sampled.period || !start || ((_c = sampled.dimensions) !== null && _c !== void 0 ? _c : 1) !== 1) {
54
+ return undefined;
55
+ }
56
+ const origin = (_e = (_d = sampled.origin) === null || _d === void 0 ? void 0 : _d.value) !== null && _e !== void 0 ? _e : 0;
57
+ const factor = (_f = sampled.factor) !== null && _f !== void 0 ? _f : 1;
58
+ return {
59
+ start: new Date(start),
60
+ periodMs: sampled.period,
61
+ values: sampled.data.trim().split(/\s+/).map((token) => {
62
+ const value = Number(token);
63
+ // E, L and U mark a slot without a value.
64
+ return Number.isFinite(value) ? origin + factor * value : null;
65
+ }),
66
+ };
67
+ };
68
+ exports.readSeries = readSeries;
69
+ /** The lowest, highest and mean of a series' values, or `undefined` when it holds none. */
70
+ const summarizeSeries = (values) => {
71
+ let min = Infinity, max = -Infinity, total = 0, count = 0;
72
+ // A loop, not Math.min(...values): an ECG holds more values than a call takes arguments.
73
+ for (const value of values) {
74
+ if (value !== null) {
75
+ min = Math.min(min, value);
76
+ max = Math.max(max, value);
77
+ total += value;
78
+ count++;
79
+ }
80
+ }
81
+ return count === 0 ? undefined : { min, max, mean: total / count, count };
82
+ };
83
+ exports.summarizeSeries = summarizeSeries;
@@ -0,0 +1,14 @@
1
+ import { MeasurementTypeKey } from "../MeasurementTypeKey";
2
+ /**
3
+ * One kind of measurement taken at a fixed interval, such as the heart rate a strap sends every second: one value
4
+ * per slot for each measured property, and `null` for a slot nothing arrived in.
5
+ */
6
+ export type MeasurementSeries = {
7
+ measurementTypeKey: MeasurementTypeKey;
8
+ /** When the first slot starts. */
9
+ start: Date;
10
+ /** Milliseconds from one slot to the next. */
11
+ periodMs: number;
12
+ /** By measured property (the keys `MEASUREMENT_FIELDS` lists), one value per slot. */
13
+ values: Partial<Record<string, ReadonlyArray<number | null>>>;
14
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -18,3 +18,4 @@ export * from "./WalkingHeartRateAverageMeasurement";
18
18
  export * from "./LipidPanelMeasurement";
19
19
  export * from "./RespiratoryTherapyMeasurement";
20
20
  export * from "./BabySleepMeasurement";
21
+ export * from "./MeasurementSeries";
@@ -34,3 +34,4 @@ __exportStar(require("./WalkingHeartRateAverageMeasurement"), exports);
34
34
  __exportStar(require("./LipidPanelMeasurement"), exports);
35
35
  __exportStar(require("./RespiratoryTherapyMeasurement"), exports);
36
36
  __exportStar(require("./BabySleepMeasurement"), exports);
37
+ __exportStar(require("./MeasurementSeries"), exports);
@@ -31,7 +31,16 @@ export type OfflineMeasurementFailure = {
31
31
  };
32
32
  type MeasurementTransport = (bundle: Bundle, requestOptions?: MedplumRequestOptions) => Promise<Bundle>;
33
33
  declare const stableMeasurementId: (observation: Observation) => string;
34
- declare const entryForObservation: (observation: Observation) => BundleEntry<Observation>;
34
+ /** How a save names and writes each observation; `SaveObservationsOptions` describes both. */
35
+ type ObservationEntryOptions = {
36
+ identifierSystems?: readonly string[];
37
+ replace?: boolean;
38
+ };
39
+ /**
40
+ * The bundle entry that saves `observation` once: created only if no observation of its subject has its content id
41
+ * or one of its reading identifiers, or with `replace`, written over the one saved under its reading identifiers.
42
+ */
43
+ declare const entryForObservation: (observation: Observation, { identifierSystems, replace }?: ObservationEntryOptions) => BundleEntry<Observation>;
35
44
  export declare class OfflineMeasurementQueue {
36
45
  private readonly storage;
37
46
  private readonly transport;
@@ -42,7 +51,7 @@ export declare class OfflineMeasurementQueue {
42
51
  private readonly getOwnerKey?;
43
52
  private operation;
44
53
  constructor(storage: IClientStorage, transport: MeasurementTransport, options?: OfflineMeasurementQueueOptions);
45
- enqueueAndFlush: (observations: Observation[], requestOptions?: MedplumRequestOptions) => Promise<{
54
+ enqueueAndFlush: (observations: Observation[], requestOptions?: MedplumRequestOptions, entryOptions?: ObservationEntryOptions) => Promise<{
46
55
  id: string;
47
56
  flush: OfflineMeasurementFlushResult;
48
57
  }>;
@@ -52,4 +61,5 @@ export declare class OfflineMeasurementQueue {
52
61
  private writeQueue;
53
62
  private flushQueue;
54
63
  }
64
+ export type { ObservationEntryOptions };
55
65
  export { OFFLINE_MEASUREMENT_QUEUE_STORAGE_KEY as DEFAULT_OFFLINE_MEASUREMENT_QUEUE_KEY, IDENTIFIER_SYSTEM as OFFLINE_MEASUREMENT_IDENTIFIER_SYSTEM, entryForObservation, stableMeasurementId, };
@@ -62,28 +62,55 @@ const stableMeasurementId = (observation) => {
62
62
  return stableValueId(legacyObservation);
63
63
  };
64
64
  exports.stableMeasurementId = stableMeasurementId;
65
- const entryForObservation = (observation) => {
66
- var _a, _b;
65
+ /** The observation's identifiers in `systems`, as `system|value` search tokens. */
66
+ const readingIdentifiers = (observation, systems) => {
67
+ var _a;
68
+ return ((_a = observation.identifier) !== null && _a !== void 0 ? _a : [])
69
+ .filter(({ system, value }) => system && value && systems.includes(system))
70
+ .map(({ system, value }) => `${system}|${value}`);
71
+ };
72
+ /** A search for an observation of the same subject carrying any of `identifiers`. */
73
+ const searchFor = (observation, identifiers) => {
74
+ var _a;
75
+ const subject = (_a = observation.subject) === null || _a === void 0 ? void 0 : _a.reference;
76
+ return [
77
+ `identifier=${identifiers.map(encodeURIComponent).join(",")}`,
78
+ ...(subject ? [`subject=${encodeURIComponent(subject)}`] : []),
79
+ ].join("&");
80
+ };
81
+ /**
82
+ * The bundle entry that saves `observation` once: created only if no observation of its subject has its content id
83
+ * or one of its reading identifiers, or with `replace`, written over the one saved under its reading identifiers.
84
+ */
85
+ const entryForObservation = (observation, { identifierSystems = [], replace = false } = {}) => {
86
+ var _a;
67
87
  const id = stableMeasurementId(observation);
68
- const identifier = {
69
- system: IDENTIFIER_SYSTEM,
70
- value: id,
88
+ const fullUrl = `urn:uuid:${id}`;
89
+ const resource = {
90
+ ...observation,
91
+ identifier: [...((_a = observation.identifier) !== null && _a !== void 0 ? _a : []), { system: IDENTIFIER_SYSTEM, value: id }],
71
92
  };
72
- const sourceIdentifier = (_a = observation.identifier) === null || _a === void 0 ? void 0 : _a.find((item) => item.system === fhir_systems_1.DEVICE_READING_IDENTIFIER_SYSTEM);
73
- const ifNoneExist = sourceIdentifier
74
- ? `identifier=${encodeURIComponent(`${IDENTIFIER_SYSTEM}|${id}`)},${encodeURIComponent(`${fhir_systems_1.DEVICE_READING_IDENTIFIER_SYSTEM}|${sourceIdentifier.value}`)}`
75
- : `identifier=${IDENTIFIER_SYSTEM}|${id}`;
93
+ const readings = readingIdentifiers(observation, [
94
+ fhir_systems_1.DEVICE_READING_IDENTIFIER_SYSTEM,
95
+ ...identifierSystems,
96
+ ]);
97
+ if (replace && readings.length > 0) {
98
+ // Medplum refuses a create-by-update that names an id.
99
+ delete resource.id;
100
+ return {
101
+ fullUrl,
102
+ request: { method: "PUT", url: `Observation?${searchFor(observation, readings)}` },
103
+ resource,
104
+ };
105
+ }
76
106
  return {
77
- fullUrl: `urn:uuid:${id}`,
107
+ fullUrl,
78
108
  request: {
79
109
  method: "POST",
80
110
  url: "Observation",
81
- ifNoneExist,
82
- },
83
- resource: {
84
- ...observation,
85
- identifier: [...((_b = observation.identifier) !== null && _b !== void 0 ? _b : []), identifier],
111
+ ifNoneExist: searchFor(observation, [`${IDENTIFIER_SYSTEM}|${id}`, ...readings]),
86
112
  },
113
+ resource,
87
114
  };
88
115
  };
89
116
  exports.entryForObservation = entryForObservation;
@@ -119,14 +146,14 @@ class OfflineMeasurementQueue {
119
146
  this.storage = storage;
120
147
  this.transport = transport;
121
148
  this.operation = Promise.resolve();
122
- this.enqueueAndFlush = (observations, requestOptions) => {
149
+ this.enqueueAndFlush = (observations, requestOptions, entryOptions) => {
123
150
  var _a;
124
151
  const ownerKey = (_a = this.getOwnerKey) === null || _a === void 0 ? void 0 : _a.call(this);
125
152
  return this.runExclusive(async () => {
126
153
  var _a, _b;
127
154
  await ((_b = (_a = this.storage).getInitPromise) === null || _b === void 0 ? void 0 : _b.call(_a));
128
155
  const queue = this.readQueue();
129
- const entries = observations.map(entryForObservation);
156
+ const entries = observations.map((observation) => entryForObservation(observation, entryOptions));
130
157
  if (this.getOwnerKey && !ownerKey) {
131
158
  throw new Error("Offline measurements require an active account to prevent cross-account delivery.");
132
159
  }
@@ -32,6 +32,20 @@ export type SaveObservationsResult = {
32
32
  status: "queued";
33
33
  queueId: string;
34
34
  };
35
+ /** Medplum's request options, and how a save names and writes its observations. */
36
+ export type SaveObservationsOptions = MedplumRequestOptions & {
37
+ /**
38
+ * Identifier systems that name a reading for good, such as an app's own reading key. An observation carrying an
39
+ * identifier in one of them is created only if no observation of its subject has that identifier, so a reading
40
+ * delivered again, or saved before under that key, is saved once. The SDK's device-reading id always counts.
41
+ */
42
+ identifierSystems?: readonly string[];
43
+ /**
44
+ * Write over the observation saved under the same reading identifiers instead of keeping it, for a value that
45
+ * changes, such as a day's step total. Needs a reading identifier; an observation without one is created once.
46
+ */
47
+ replace?: boolean;
48
+ };
35
49
  export type OvokFhirVersion = "R4" | "R5";
36
50
  export type OvokClientOptions = Omit<MedplumClientOptions, "onUnauthenticated"> & {
37
51
  onUnauthenticated?: (reason: OvokUnauthenticatedReason) => void;
@@ -81,18 +95,27 @@ export declare class OvokClient extends MedplumClient {
81
95
  /** Reauthentication clears the expired marker for the account that signed in. */
82
96
  setActiveLogin(login: LoginState): Promise<void>;
83
97
  /** Saves a measurement immediately, or durably queues it when the opt-in queue is enabled. */
84
- saveMeasurement(params: GenerateObservationBodyParams, requestOptions?: MedplumRequestOptions): Promise<SaveObservationsResult>;
98
+ saveMeasurement(params: GenerateObservationBodyParams, options?: SaveObservationsOptions): Promise<SaveObservationsResult>;
85
99
  /** Reads Ovok's composed FHIR and public-route capability statement. */
86
100
  getCapabilityStatement(version?: OvokFhirVersion, requestOptions?: MedplumRequestOptions): Promise<CapabilityStatement>;
87
101
  /** Returns the non-FHIR public routes published by the backend statement. */
88
102
  getPublicApiEndpoints(version?: OvokFhirVersion, requestOptions?: MedplumRequestOptions): Promise<OvokPublicApiEndpoint[]>;
89
- /** Saves observations with conditional creates so a retry cannot create a duplicate. */
90
- saveObservations(observations: Observation[], requestOptions?: MedplumRequestOptions): Promise<SaveObservationsResult>;
103
+ /**
104
+ * Saves one reading's observations, main first, with conditional creates so a retry cannot create a duplicate.
105
+ * A reading too big for one request goes over several, and stops if the signed-in account changes in between.
106
+ */
107
+ saveObservations(observations: Observation[], options?: SaveObservationsOptions): Promise<SaveObservationsResult>;
108
+ /**
109
+ * Saves many readings at once, such as a page of a health import: each group is one reading's observations, main
110
+ * first, linked and named as `saveObservations` does. Readings share batch requests as far as they fit, each whole;
111
+ * one too big for a request goes alone and is split. It is never queued offline: an import reads its source again.
112
+ */
113
+ saveObservationGroups(groups: Observation[][], options?: SaveObservationsOptions): Promise<Bundle>;
91
114
  /** Flushes measurements queued while offline. Call this on connectivity/app-resume events. */
92
115
  flushOfflineMeasurementQueue(options?: OfflineMeasurementFlushOptions, requestOptions?: MedplumRequestOptions): Promise<OfflineMeasurementFlushResult>;
93
116
  private getOfflineMeasurementQueue;
94
- /** Queued measurements belong to one account on one server and project. */
95
- private offlineOwnerKey;
117
+ /** The signed-in account, on its server and project: what queued measurements and a running save belong to. */
118
+ private accountKey;
96
119
  /**
97
120
  * Medplum clears its entire client storage after an unrecoverable 401. Keep
98
121
  * saved accounts (marking only the active one expired) and queued measurements.
@@ -108,7 +131,10 @@ export declare class OvokClient extends MedplumClient {
108
131
  private removeSavedAccount;
109
132
  private syncSavedAccountProfile;
110
133
  private restoreActiveLogin;
111
- private executeConditionalObservationCreates;
134
+ /**
135
+ * Sends a save's bundle, over several requests when it must. A later request isn't sent once the signed-in
136
+ * account differs from `account`: the rest of a reading is never written under someone else's sign-in.
137
+ */
112
138
  private executeObservationBundle;
113
139
  private bindMethods;
114
140
  /**
@@ -68,6 +68,14 @@ const slimMethods = __importStar(require("./slim/methods"));
68
68
  var observation_bundle_utils_2 = require("./observation/observation-bundle-utils");
69
69
  Object.defineProperty(exports, "MAX_CONDITIONAL_OBSERVATION_ENTRIES", { enumerable: true, get: function () { return observation_bundle_utils_2.MAX_CONDITIONAL_OBSERVATION_ENTRIES; } });
70
70
  Object.defineProperty(exports, "MAX_OBSERVATION_BUNDLE_BYTES", { enumerable: true, get: function () { return observation_bundle_utils_2.MAX_OBSERVATION_BUNDLE_BYTES; } });
71
+ /** A save's own options apart from the request options it passes on. */
72
+ const splitSaveOptions = (options) => {
73
+ if (!options) {
74
+ return [{}, undefined];
75
+ }
76
+ const { identifierSystems, replace, ...requestOptions } = options;
77
+ return [{ identifierSystems, replace }, requestOptions];
78
+ };
71
79
  const isRetrySafePost = (pathname) => pathname === "/ai/fhir/search";
72
80
  /** A failed profile load is tried again after this long, doubling up to the maximum. */
73
81
  const PROFILE_RETRY_MS = 2000;
@@ -312,9 +320,9 @@ class OvokClient extends core_1.MedplumClient {
312
320
  }
313
321
  }
314
322
  /** Saves a measurement immediately, or durably queues it when the opt-in queue is enabled. */
315
- async saveMeasurement(params, requestOptions) {
323
+ async saveMeasurement(params, options) {
316
324
  const observations = await this.generateObservationBodiesByMeasurement(params);
317
- return this.saveObservations(observations, ...(0, request_options_1.optionalArg)(requestOptions));
325
+ return this.saveObservations(observations, ...(0, request_options_1.optionalArg)(options));
318
326
  }
319
327
  /** Reads Ovok's composed FHIR and public-route capability statement. */
320
328
  async getCapabilityStatement(version = "R4", requestOptions) {
@@ -324,20 +332,51 @@ class OvokClient extends core_1.MedplumClient {
324
332
  async getPublicApiEndpoints(version = "R4", requestOptions) {
325
333
  return (0, capability_1.getOvokPublicApiEndpoints)(await this.getCapabilityStatement(version, ...(0, request_options_1.optionalArg)(requestOptions)));
326
334
  }
327
- /** Saves observations with conditional creates so a retry cannot create a duplicate. */
328
- async saveObservations(observations, requestOptions) {
335
+ /**
336
+ * Saves one reading's observations, main first, with conditional creates so a retry cannot create a duplicate.
337
+ * A reading too big for one request goes over several, and stops if the signed-in account changes in between.
338
+ */
339
+ async saveObservations(observations, options) {
340
+ const [entryOptions, requestOptions] = splitSaveOptions(options);
329
341
  const linkedObservations = (0, observation_bundle_utils_1.linkObservationMembers)(observations);
330
342
  const queue = this.getOfflineMeasurementQueue();
331
343
  if (queue) {
332
- const { id, flush } = await queue.enqueueAndFlush(linkedObservations, ...(0, request_options_1.optionalArg)(requestOptions));
344
+ const { id, flush } = await queue.enqueueAndFlush(linkedObservations, requestOptions, entryOptions);
333
345
  const response = flush.responses[id];
334
346
  return response
335
347
  ? { status: "saved", response }
336
348
  : { status: "queued", queueId: id };
337
349
  }
338
- const response = await this.executeConditionalObservationCreates(linkedObservations, requestOptions);
350
+ const response = await this.executeObservationBundle({
351
+ resourceType: "Bundle",
352
+ type: "transaction",
353
+ entry: linkedObservations.map((observation) => (0, offline_1.entryForObservation)(observation, entryOptions)),
354
+ }, ...(0, request_options_1.optionalArg)(requestOptions));
339
355
  return { status: "saved", response };
340
356
  }
357
+ /**
358
+ * Saves many readings at once, such as a page of a health import: each group is one reading's observations, main
359
+ * first, linked and named as `saveObservations` does. Readings share batch requests as far as they fit, each whole;
360
+ * one too big for a request goes alone and is split. It is never queued offline: an import reads its source again.
361
+ */
362
+ async saveObservationGroups(groups, options) {
363
+ const [entryOptions, requestOptions] = splitSaveOptions(options);
364
+ const account = this.accountKey();
365
+ const entryGroups = groups
366
+ .filter((group) => group.length > 0)
367
+ .map((group) => (0, observation_bundle_utils_1.linkObservationMembers)(group).map((observation) => (0, offline_1.entryForObservation)(observation, entryOptions)));
368
+ const responses = [];
369
+ for (const request of (0, observation_bundle_utils_1.packObservationGroups)(entryGroups)) {
370
+ // A reading alone keeps a single save's atomicity; several share a batch.
371
+ const type = request.length === 1 ? "transaction" : "batch";
372
+ responses.push(await this.executeObservationBundle({ resourceType: "Bundle", type, entry: request.flat() }, requestOptions, account));
373
+ }
374
+ return {
375
+ resourceType: "Bundle",
376
+ type: "batch-response",
377
+ entry: responses.flatMap((response) => { var _a; return (_a = response.entry) !== null && _a !== void 0 ? _a : []; }),
378
+ };
379
+ }
341
380
  /** Flushes measurements queued while offline. Call this on connectivity/app-resume events. */
342
381
  async flushOfflineMeasurementQueue(options = {}, requestOptions) {
343
382
  const queue = this.getOfflineMeasurementQueue();
@@ -363,12 +402,12 @@ class OvokClient extends core_1.MedplumClient {
363
402
  }
364
403
  (_a = this.offlineMeasurementQueue) !== null && _a !== void 0 ? _a : (this.offlineMeasurementQueue = new offline_1.OfflineMeasurementQueue(this.clientStorage, (bundle, requestOptions) => this.executeObservationBundle(bundle, ...(0, request_options_1.optionalArg)(requestOptions)), {
365
404
  ...this.offlineQueueOptions,
366
- getOwnerKey: () => this.offlineOwnerKey(),
405
+ getOwnerKey: () => this.accountKey(),
367
406
  }));
368
407
  return this.offlineMeasurementQueue;
369
408
  }
370
- /** Queued measurements belong to one account on one server and project. */
371
- offlineOwnerKey() {
409
+ /** The signed-in account, on its server and project: what queued measurements and a running save belong to. */
410
+ accountKey() {
372
411
  var _a, _b, _c;
373
412
  const login = this.getActiveLogin();
374
413
  const profileReference = (_a = login === null || login === void 0 ? void 0 : login.profile) === null || _a === void 0 ? void 0 : _a.reference;
@@ -474,16 +513,17 @@ class OvokClient extends core_1.MedplumClient {
474
513
  // Keep the saved login available for a later retry if restoration is offline.
475
514
  }
476
515
  }
477
- async executeConditionalObservationCreates(observations, requestOptions) {
478
- const entries = observations.map(offline_1.entryForObservation);
479
- return this.executeObservationBundle({
480
- resourceType: "Bundle",
481
- type: "transaction",
482
- entry: entries,
483
- }, ...(0, request_options_1.optionalArg)(requestOptions));
484
- }
485
- async executeObservationBundle(bundle, requestOptions) {
486
- const response = await (0, observation_bundle_utils_1.executeObservationBundle)(bundle, (request) => this.executeBatch(request, ...(0, request_options_1.optionalArg)(requestOptions)));
516
+ /**
517
+ * Sends a save's bundle, over several requests when it must. A later request isn't sent once the signed-in
518
+ * account differs from `account`: the rest of a reading is never written under someone else's sign-in.
519
+ */
520
+ async executeObservationBundle(bundle, requestOptions, account = this.accountKey()) {
521
+ const response = await (0, observation_bundle_utils_1.executeObservationBundle)(bundle, (request) => {
522
+ if (this.accountKey() !== account) {
523
+ throw new Error("The signed-in account changed during the save; the rest of it was not sent.");
524
+ }
525
+ return this.executeBatch(request, ...(0, request_options_1.optionalArg)(requestOptions));
526
+ });
487
527
  this.invalidateSearches("Observation");
488
528
  return response;
489
529
  }
@@ -48,7 +48,7 @@ exports.OVOK_CORE_REQUIREMENTS = {
48
48
  description: "The FHIR surface a server must provide for the @ovok/core client library's own methods to function.",
49
49
  software: {
50
50
  name: "@ovok/core",
51
- version: "0.4.40",
51
+ version: "0.4.41",
52
52
  },
53
53
  fhirVersion: "4.0.1",
54
54
  format: ["json"],
package/dist/index.d.ts CHANGED
@@ -934,13 +934,14 @@ export type { OvokSavedAccount } from "./client/auth/types/Account";
934
934
  export { LoginError } from "./client/auth/types/LoginError";
935
935
  export type { LoginProfile, TenantLoginStart } from "./client/auth/types/LoginError";
936
936
  export { MAX_CONDITIONAL_OBSERVATION_ENTRIES, MAX_OBSERVATION_BUNDLE_BYTES, } from "./client/ovok-client";
937
- export type { SaveObservationsResult } from "./client/ovok-client";
937
+ export type { SaveObservationsOptions, SaveObservationsResult } from "./client/ovok-client";
938
938
  export * from "./client/offline";
939
939
  export * from "./hooks";
940
940
  export * from "./utils";
941
941
  export * from "./client/observation/services";
942
942
  export * from "./client/observation/methods";
943
943
  export * from "./client/observation/measurement-fields";
944
+ export * from "./client/observation/series";
944
945
  export { saveMeasurement } from "./client/observation/methods/saveMeasurement";
945
946
  export * from "./conformance/capability-requirements";
946
947
  export * from "./conformance/api-requirements";
package/dist/index.js CHANGED
@@ -59,6 +59,7 @@ __exportStar(require("./utils"), exports);
59
59
  __exportStar(require("./client/observation/services"), exports);
60
60
  __exportStar(require("./client/observation/methods"), exports);
61
61
  __exportStar(require("./client/observation/measurement-fields"), exports);
62
+ __exportStar(require("./client/observation/series"), exports);
62
63
  var saveMeasurement_1 = require("./client/observation/methods/saveMeasurement");
63
64
  Object.defineProperty(exports, "saveMeasurement", { enumerable: true, get: function () { return saveMeasurement_1.saveMeasurement; } });
64
65
  __exportStar(require("./conformance/capability-requirements"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovok/core",
3
- "version": "0.4.40",
3
+ "version": "0.4.41",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",