@oystehr/sdk 4.3.3 → 4.3.4

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,6 +1,6 @@
1
1
  import type { Operation } from 'fast-json-patch';
2
- import type { Binary as BinaryR4B, Bundle as BundleR4B, BundleEntry as BundleEntryR4B, FhirResource as FhirResourceR4B, OperationOutcome as OperationOutcomeR4B } from 'fhir/r4b';
3
- import type { Binary as BinaryR5, Bundle as BundleR5, BundleEntry as BundleEntryR5, FhirResource as FhirResourceR5, OperationOutcome as OperationOutcomeR5 } from 'fhir/r5';
2
+ import type { Binary as BinaryR4B, Bundle as BundleR4B, BundleEntry as BundleEntryR4B, Coding as CodingR4B, FhirResource as FhirResourceR4B, OperationOutcome as OperationOutcomeR4B, Resource as ResourceR4B } from 'fhir/r4b';
3
+ import type { Binary as BinaryR5, Bundle as BundleR5, BundleEntry as BundleEntryR5, Coding as CodingR5, FhirResource as FhirResourceR5, OperationOutcome as OperationOutcomeR5, Resource as ResourceR5 } from 'fhir/r5';
4
4
  export type FhirResource = FhirResourceR4B | FhirResourceR5;
5
5
  export type FhirBundle<F extends FhirResource> = F extends FhirResourceR4B ? BundleR4B<F> : BundleR5<F>;
6
6
  export type EntrylessFhirBundle<F extends FhirResource> = F extends FhirResourceR4B ? Omit<BundleR4B<F>, 'entry'> : Omit<BundleR5<F>, 'entry'>;
@@ -28,6 +28,8 @@ export type TransactionBundle<F extends FhirResource> = EntrylessFhirBundle<F> &
28
28
  export type BundleEntry<F extends FhirResource> = F extends FhirResourceR4B ? BundleEntryR4B<F> : BundleEntryR5<F>;
29
29
  export type Binary<F extends FhirResource> = F extends FhirResourceR4B ? BinaryR4B : BinaryR5;
30
30
  export type OperationOutcome = OperationOutcomeR4B | OperationOutcomeR5;
31
+ export type Coding = CodingR4B | CodingR5;
32
+ export type Resource = ResourceR4B | ResourceR5;
31
33
  export type SearchParam = {
32
34
  name: string;
33
35
  value: string | number;
@@ -89,6 +91,14 @@ export interface BatchInputPostRequest<F extends FhirResource> extends BatchInpu
89
91
  resource: F;
90
92
  fullUrl?: string;
91
93
  }
94
+ /**
95
+ * A batch POST to a `_search` endpoint (e.g. `Patient/_search`). Search parameters
96
+ * may be appended to the URL as query parameters. Unlike a create POST there is no
97
+ * resource body.
98
+ */
99
+ export interface BatchInputSearchPostRequest extends BatchInputBaseRequest {
100
+ method: 'POST';
101
+ }
92
102
  export interface BatchInputBinaryPatchRequest<F extends FhirResource> extends BatchInputBaseRequest {
93
103
  method: 'PATCH';
94
104
  ifMatch?: string;
@@ -100,7 +110,7 @@ export interface BatchInputJSONPatchRequest extends BatchInputBaseRequest {
100
110
  operations: Operation[];
101
111
  }
102
112
  export type BatchInputPatchRequest<F extends FhirResource> = BatchInputBinaryPatchRequest<F> | BatchInputJSONPatchRequest;
103
- export type BatchInputRequest<F extends FhirResource> = BatchInputGetRequest | BatchInputHeadRequest | BatchInputPutRequest<F> | BatchInputPatchRequest<F> | BatchInputPostRequest<F> | BatchInputDeleteRequest;
113
+ export type BatchInputRequest<F extends FhirResource> = BatchInputGetRequest | BatchInputHeadRequest | BatchInputPutRequest<F> | BatchInputPatchRequest<F> | BatchInputPostRequest<F> | BatchInputSearchPostRequest | BatchInputDeleteRequest;
104
114
  export interface BatchInput<F extends FhirResource> {
105
115
  requests: BatchInputRequest<F>[];
106
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oystehr/sdk",
3
- "version": "4.3.3",
3
+ "version": "4.3.4",
4
4
  "description": "Oystehr SDK",
5
5
  "scripts": {
6
6
  "lint": "eslint .",
package/src/config.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { Coding } from './resources/types/fhir';
2
+
1
3
  /**
2
4
  * Configuration for the Oystehr SDK client
3
5
  */
@@ -62,6 +64,20 @@ export interface OystehrConfig {
62
64
  */
63
65
  retryOn?: number[];
64
66
  };
67
+ /**
68
+ * Optional workspace tag configuration. When set, all FHIR searches will be
69
+ * filtered to only return resources with this tag, and all FHIR mutations
70
+ * (create, update, patch) will have this tag injected automatically.
71
+ * Mutually exclusive with `ignoreTags`.
72
+ */
73
+ workspaceTag?: Coding;
74
+ /**
75
+ * Optional list of tags to ignore. When set, all FHIR searches will be
76
+ * filtered to exclude resources matching any of these tags, and mutations
77
+ * will throw an error if the resource carries any of these tags.
78
+ * Mutually exclusive with `workspaceTag`.
79
+ */
80
+ ignoreTags?: Coding[];
65
81
  }
66
82
 
67
83
  let globalConfig: OystehrConfig;
@@ -1,3 +1,4 @@
1
+ import type { Operation } from 'fast-json-patch';
1
2
  import { Address as AddressR4B, HumanName as HumanNameR4B } from 'fhir/r4b';
2
3
  import { Address as AddressR5, HumanName as HumanNameR5 } from 'fhir/r5';
3
4
  import {
@@ -7,6 +8,7 @@ import {
7
8
  Binary,
8
9
  Bundle,
9
10
  BundleEntry,
11
+ Coding,
10
12
  FhirBundle,
11
13
  FhirCreateParams,
12
14
  FhirDeleteParams,
@@ -19,9 +21,13 @@ import {
19
21
  FhirSearchParams,
20
22
  FhirUpdateParams,
21
23
  OperationOutcome,
24
+ Resource,
25
+ SearchParam,
22
26
  TransactionBundle,
23
27
  } from '../..';
24
28
  import { addParamsToSearch, FhirFetcherResponse, OystehrClientRequest, SDKResource } from '../../client/client';
29
+ import { OystehrConfig } from '../../config';
30
+ import { OystehrFHIRError, OystehrSdkError } from '../../errors';
25
31
 
26
32
  // Code adapted from https://github.com/sindresorhus/uint8array-extras
27
33
  const MAX_BLOCK_SIZE = 65_535;
@@ -37,6 +43,209 @@ function stringToBase64(input: string): string {
37
43
  return base64;
38
44
  }
39
45
 
46
+ function base64ToString(input: string): string {
47
+ const binaryString = globalThis.atob(input);
48
+ const bytes = new Uint8Array(binaryString.length);
49
+ for (let i = 0; i < binaryString.length; i++) {
50
+ bytes[i] = binaryString.charCodeAt(i);
51
+ }
52
+ return new globalThis.TextDecoder().decode(bytes);
53
+ }
54
+
55
+ // ─── Tag-mode helpers ────────────────────────────────────────────────────────
56
+
57
+ /** Converts a Coding to the FHIR _tag parameter value format "system|code". */
58
+ function codingToTagValue(coding: Coding): string {
59
+ return coding.system ? `${coding.system}|${coding.code ?? ''}` : coding.code ?? '';
60
+ }
61
+
62
+ /** Returns true if two Codings match on system (treating absent as empty string) and code. */
63
+ function codingMatches(a: Coding, b: Coding): boolean {
64
+ return (a.system ?? '') === (b.system ?? '') && (a.code ?? '') === (b.code ?? '');
65
+ }
66
+
67
+ /**
68
+ * Appends _tag (tag-workspace mode) or _tag:not (ignore-tags mode) search
69
+ * params to an existing SearchParam array and returns the new array.
70
+ */
71
+ function applyTagSearchParams(config: OystehrConfig, params: SearchParam[] | undefined): SearchParam[] {
72
+ const out: SearchParam[] = params ? [...params] : [];
73
+ if (config.workspaceTag) {
74
+ out.push({ name: '_tag', value: codingToTagValue(config.workspaceTag) });
75
+ } else if (config.ignoreTags?.length) {
76
+ for (const tag of config.ignoreTags) {
77
+ out.push({ name: '_tag:not', value: codingToTagValue(tag) });
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * In ignore-tags mode: throws OystehrSdkError if the resource carries any ignored tag.
85
+ * In tag-workspace mode: injects the workspace tag into resource.meta.tag if not already present.
86
+ * Returns the (possibly cloned) resource.
87
+ */
88
+ function applyTagToResource<T extends FhirResource>(config: OystehrConfig, resource: T): T {
89
+ const resourceLike = resource as unknown as Resource;
90
+
91
+ if (config.ignoreTags?.length) {
92
+ const resourceTags = (resourceLike.meta?.tag ?? []) as Coding[];
93
+ for (const ignored of config.ignoreTags) {
94
+ if (resourceTags.some((t) => codingMatches(t, ignored))) {
95
+ throw new OystehrSdkError({
96
+ message: `Resource has an ignored tag (system: "${ignored.system ?? ''}", code: "${
97
+ ignored.code ?? ''
98
+ }") and cannot be mutated in ignoreTags mode`,
99
+ code: 400,
100
+ });
101
+ }
102
+ }
103
+ return resource;
104
+ }
105
+
106
+ if (config.workspaceTag) {
107
+ const tag = config.workspaceTag;
108
+ const existingTags = (resourceLike.meta?.tag ?? []) as Coding[];
109
+ if (!existingTags.some((t) => codingMatches(t, tag))) {
110
+ return {
111
+ ...resource,
112
+ meta: {
113
+ ...resourceLike.meta,
114
+ tag: [...existingTags, tag],
115
+ },
116
+ } as T;
117
+ }
118
+ }
119
+
120
+ return resource;
121
+ }
122
+
123
+ /**
124
+ * In ignore-tags mode: throws OystehrSdkError if any operation value resembles an ignored tag.
125
+ * In tag-workspace mode: guards against removal or loss of the workspace tag:
126
+ * - add/replace of the tag array: ensures workspace tag is present in the value
127
+ * - add/replace of meta: ensures workspace tag is present in meta.tag
128
+ * - remove of /meta/tag or /meta: keeps the remove and appends an operation to restore the workspace tag
129
+ * - add of a single tag (path ends in /-): left unchanged (workspace tag already on the resource)
130
+ */
131
+ function applyTagToPatchOperations(config: OystehrConfig, operations: Operation[]): Operation[] {
132
+ if (config.ignoreTags?.length) {
133
+ for (const op of operations) {
134
+ if (op.op === 'add' || op.op === 'replace' || op.op === 'test') {
135
+ const opValue: unknown = op.value;
136
+ if (opValue !== null && opValue !== undefined && typeof opValue === 'object') {
137
+ const v = opValue as Coding;
138
+ if (v.code !== undefined) {
139
+ for (const ignored of config.ignoreTags) {
140
+ if (codingMatches(v, ignored)) {
141
+ throw new OystehrSdkError({
142
+ message: `Patch operation contains an ignored tag (system: "${ignored.system ?? ''}", code: "${
143
+ ignored.code ?? ''
144
+ }") and cannot be applied in ignoreTags mode`,
145
+ code: 400,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+ return operations;
154
+ }
155
+
156
+ if (config.workspaceTag) {
157
+ const tag = config.workspaceTag;
158
+ return operations.reduce<Operation[]>((result, op) => {
159
+ // remove /meta/tag — restore workspace tag afterwards
160
+ if (op.op === 'remove' && op.path === '/meta/tag') {
161
+ result.push(op);
162
+ result.push({ op: 'add' as const, path: '/meta/tag', value: [tag] });
163
+ return result;
164
+ }
165
+
166
+ // remove /meta — restore workspace tag afterwards
167
+ if (op.op === 'remove' && op.path === '/meta') {
168
+ result.push(op);
169
+ result.push({ op: 'add' as const, path: '/meta', value: { tag: [tag] } });
170
+ return result;
171
+ }
172
+
173
+ // add/replace the entire tag array — ensure workspace tag is present
174
+ if ((op.op === 'add' || op.op === 'replace') && op.path === '/meta/tag') {
175
+ const tags: Coding[] = Array.isArray(op.value) ? (op.value as Coding[]) : [];
176
+ if (!tags.some((t) => codingMatches(t, tag))) {
177
+ result.push({ ...op, value: [...tags, tag] });
178
+ } else {
179
+ result.push(op);
180
+ }
181
+ return result;
182
+ }
183
+
184
+ // add/replace the entire meta object — ensure workspace tag is present in meta.tag
185
+ if ((op.op === 'add' || op.op === 'replace') && op.path === '/meta') {
186
+ const metaValue: { tag?: Coding[] } =
187
+ op.value != null && typeof op.value === 'object' ? (op.value as { tag?: Coding[] }) : {};
188
+ const tags = metaValue.tag ?? [];
189
+ if (!tags.some((t) => codingMatches(t, tag))) {
190
+ result.push({ ...op, value: { ...metaValue, tag: [...tags, tag] } });
191
+ } else {
192
+ result.push(op);
193
+ }
194
+ return result;
195
+ }
196
+
197
+ // All other operations (including add /meta/tag/- for individual tags) pass through unchanged.
198
+ // Workspace tag is assumed already present on the resource from create/update.
199
+ result.push(op);
200
+ return result;
201
+ }, []);
202
+ }
203
+
204
+ return operations;
205
+ }
206
+
207
+ /**
208
+ * Throws OystehrFHIRError (404) if the retrieved resource violates the tag configuration:
209
+ * - workspaceTag mode: resource must carry the workspace tag
210
+ * - ignoreTags mode: resource must not carry any ignored tag
211
+ */
212
+ function assertRetrievedResource(config: OystehrConfig, resource: FhirResource): void {
213
+ const resourceLike = resource as unknown as Resource;
214
+ const resourceTags = (resourceLike.meta?.tag ?? []) as Coding[];
215
+
216
+ if (config.workspaceTag) {
217
+ const tag = config.workspaceTag;
218
+ if (!resourceTags.some((t) => codingMatches(t, tag))) {
219
+ throw new OystehrFHIRError({
220
+ error: {
221
+ resourceType: 'OperationOutcome',
222
+ id: 'not-found',
223
+ issue: [{ severity: 'error', code: 'not-found', details: { text: 'Not found' } }],
224
+ },
225
+ code: 404,
226
+ });
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (config.ignoreTags?.length) {
232
+ for (const ignored of config.ignoreTags) {
233
+ if (resourceTags.some((t) => codingMatches(t, ignored))) {
234
+ throw new OystehrFHIRError({
235
+ error: {
236
+ resourceType: 'OperationOutcome',
237
+ id: 'not-found',
238
+ issue: [{ severity: 'error', code: 'not-found', details: { text: 'Not found' } }],
239
+ },
240
+ code: 404,
241
+ });
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // ─────────────────────────────────────────────────────────────────────────────
248
+
40
249
  /**
41
250
  * Optional parameter that can be passed to the client methods. It allows
42
251
  * overriding the access token or project ID, and setting various headers,
@@ -64,10 +273,11 @@ export async function search<T extends FhirResource>(
64
273
  params: FhirSearchParams<T>,
65
274
  request?: OystehrClientRequest
66
275
  ): Promise<FhirFetcherResponse<Bundle<T>>> {
67
- const { resourceType, params: searchParams } = params;
276
+ const { resourceType } = params;
277
+ const taggedParams = applyTagSearchParams(this.config, params.params);
68
278
  let paramMap: Record<string, (string | number)[]> | undefined;
69
- if (searchParams) {
70
- paramMap = Object.entries(searchParams).reduce((acc, [_, param]) => {
279
+ if (taggedParams.length) {
280
+ paramMap = taggedParams.reduce((acc, param) => {
71
281
  if (!acc[param.name]) {
72
282
  acc[param.name] = [];
73
283
  }
@@ -96,8 +306,9 @@ export async function create<T extends FhirResource>(
96
306
  params: FhirCreateParams<T>,
97
307
  request?: OystehrClientRequest
98
308
  ): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
99
- const { resourceType } = params;
100
- return this.fhirRequest(`/${resourceType}`, 'POST')(params as unknown as Record<string, unknown>, request);
309
+ const tagged = applyTagToResource(this.config, params);
310
+ const { resourceType } = tagged;
311
+ return this.fhirRequest(`/${resourceType}`, 'POST')(tagged as unknown as Record<string, unknown>, request);
101
312
  }
102
313
 
103
314
  export async function get<T extends FhirResource>(
@@ -105,7 +316,9 @@ export async function get<T extends FhirResource>(
105
316
  { resourceType, id }: FhirGetParams<T>,
106
317
  request?: OystehrClientRequest
107
318
  ): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
108
- return this.fhirRequest<FhirResourceReturnValue<T>>(`/${resourceType}/${id}`, 'GET')({}, request);
319
+ const result = await this.fhirRequest<FhirResourceReturnValue<T>>(`/${resourceType}/${id}`, 'GET')({}, request);
320
+ assertRetrievedResource(this.config, result);
321
+ return result;
109
322
  }
110
323
 
111
324
  export async function update<T extends FhirResource>(
@@ -113,8 +326,9 @@ export async function update<T extends FhirResource>(
113
326
  params: FhirUpdateParams<T>,
114
327
  request?: OystehrFHIRUpdateClientRequest
115
328
  ): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
116
- const { id, resourceType } = params;
117
- return this.fhirRequest(`/${resourceType}/${id}`, 'PUT')(params as unknown as Record<string, unknown>, {
329
+ const tagged = applyTagToResource(this.config, params);
330
+ const { id, resourceType } = tagged;
331
+ return this.fhirRequest(`/${resourceType}/${id}`, 'PUT')(tagged as unknown as Record<string, unknown>, {
118
332
  ...request,
119
333
  ifMatch: request?.optimisticLockingVersionId ? `W/"${request.optimisticLockingVersionId}"` : undefined,
120
334
  });
@@ -125,7 +339,8 @@ export async function patch<T extends FhirResource>(
125
339
  { resourceType, id, operations }: FhirPatchParams<T>,
126
340
  request?: OystehrFHIRUpdateClientRequest
127
341
  ): Promise<FhirFetcherResponse<FhirResourceReturnValue<T>>> {
128
- return this.fhirRequest(`/${resourceType}/${id}`, 'PATCH')(operations, {
342
+ const taggedOperations = applyTagToPatchOperations(this.config, operations);
343
+ return this.fhirRequest(`/${resourceType}/${id}`, 'PATCH')(taggedOperations, {
129
344
  ...request,
130
345
  contentType: 'application/json-patch+json',
131
346
  ifMatch: request?.optimisticLockingVersionId ? `W/"${request.optimisticLockingVersionId}"` : undefined,
@@ -180,10 +395,50 @@ export async function history<T extends FhirResource>(
180
395
  return this.fhirRequest(`/${resourceType}/${id}/_history?_total=accurate`, 'GET')({}, request);
181
396
  }
182
397
 
398
+ /**
399
+ * Returns true when a batch GET/HEAD URL is a search (e.g. "Patient" or "Patient?name=foo")
400
+ * rather than a direct retrieval (e.g. "Patient/abc-123").
401
+ * A retrieval URL has a path segment after the resource type that is not a FHIR operation
402
+ * (operations start with "_", e.g. "_search" or "_history").
403
+ */
404
+ function isBatchSearchUrl(url: string): boolean {
405
+ const path = url.split('?')[0];
406
+ const segments = path.split('/').filter(Boolean);
407
+ // Only one segment → bare resource type, always a search
408
+ if (segments.length <= 1) return true;
409
+ // Two or more segments: second segment is an ID if it doesn't start with '_'
410
+ return segments[1].startsWith('_');
411
+ }
412
+
413
+ /** Returns true when a batch POST URL targets a `_search` endpoint (e.g. "Patient/_search"). */
414
+ function isBatchSearchPostUrl(url: string): boolean {
415
+ return url.split('?')[0].endsWith('/_search');
416
+ }
417
+
183
418
  function batchInputRequestToBundleEntryItem<T extends FhirResource>(
184
- request: BatchInputRequest<T>
419
+ request: BatchInputRequest<T>,
420
+ config: OystehrConfig
185
421
  ): BundleEntry<T | Binary<T>> {
186
- const { method, url } = request;
422
+ const { method } = request;
423
+ let url = request.url;
424
+
425
+ // Inject tag search params into search request URLs before URL encoding.
426
+ // GET/HEAD: only for search URLs (not retrievals like Patient/<id>).
427
+ // POST: only for _search endpoints (not creates).
428
+ if (
429
+ ((method === 'GET' || method === 'HEAD') && isBatchSearchUrl(url)) ||
430
+ (method === 'POST' && isBatchSearchPostUrl(url))
431
+ ) {
432
+ if (config.workspaceTag) {
433
+ url += (url.includes('?') ? '&' : '?') + `_tag=${codingToTagValue(config.workspaceTag)}`;
434
+ }
435
+ if (config.ignoreTags?.length) {
436
+ for (const tag of config.ignoreTags) {
437
+ url += (url.includes('?') ? '&' : '?') + `_tag:not=${codingToTagValue(tag)}`;
438
+ }
439
+ }
440
+ }
441
+
187
442
  const baseRequest = {
188
443
  request: {
189
444
  method,
@@ -222,7 +477,7 @@ function batchInputRequestToBundleEntryItem<T extends FhirResource>(
222
477
 
223
478
  // PUT updates require a full resource
224
479
  if (method === 'PUT') {
225
- const { resource } = request;
480
+ const resource = applyTagToResource(config, request.resource);
226
481
  return {
227
482
  request: {
228
483
  ...baseRequest.request,
@@ -235,15 +490,24 @@ function batchInputRequestToBundleEntryItem<T extends FhirResource>(
235
490
  // PATCH can be Binary resource or JSON patch
236
491
  if (method === 'PATCH') {
237
492
  if ('resource' in request) {
493
+ // Binary patch — decode operations, apply tag transforms, re-encode
494
+ let patchResource = request.resource;
495
+ const binaryData = patchResource.data;
496
+ if (binaryData) {
497
+ const operations = JSON.parse(base64ToString(binaryData)) as Operation[];
498
+ const taggedOperations = applyTagToPatchOperations(config, operations);
499
+ patchResource = { ...patchResource, data: stringToBase64(JSON.stringify(taggedOperations)) } as Binary<T>;
500
+ }
238
501
  return {
239
502
  request: {
240
503
  ...baseRequest.request,
241
504
  ifMatch: request.ifMatch,
242
505
  },
243
- resource: request.resource,
506
+ resource: patchResource,
244
507
  } as BundleEntry<Binary<T>>;
245
508
  }
246
- const data = stringToBase64(JSON.stringify(request.operations));
509
+ const operations = applyTagToPatchOperations(config, request.operations);
510
+ const data = stringToBase64(JSON.stringify(operations));
247
511
  return {
248
512
  ...baseRequest,
249
513
  resource: {
@@ -254,9 +518,15 @@ function batchInputRequestToBundleEntryItem<T extends FhirResource>(
254
518
  } as BundleEntry<Binary<T>>;
255
519
  }
256
520
 
521
+ // POST _search — no resource body; tag params were already injected into the URL above
522
+ if (method === 'POST' && isBatchSearchPostUrl(url)) {
523
+ return baseRequest as BundleEntry<T>;
524
+ }
525
+
257
526
  // POST creates require a full resource
258
- if (method === 'POST') {
259
- const { resource, fullUrl } = request;
527
+ if (method === 'POST' && 'resource' in request) {
528
+ const resource = applyTagToResource(config, request.resource);
529
+ const { fullUrl } = request;
260
530
  return {
261
531
  ...baseRequest,
262
532
  resource: resource as T,
@@ -284,13 +554,35 @@ export async function batch<BundleContentType extends FhirResource>(
284
554
  {
285
555
  resourceType: 'Bundle',
286
556
  type: 'batch',
287
- entry: input.requests.map(batchInputRequestToBundleEntryItem),
557
+ entry: input.requests.map((req) => batchInputRequestToBundleEntryItem(req, this.config)),
288
558
  },
289
559
  request
290
560
  );
561
+ // Validate each GET/HEAD retrieval entry against the tag config.
562
+ // Violations are replaced with a synthetic 404 OperationOutcome entry; batch entries are independent.
563
+ const rawEntries = resp.entry as Array<BundleEntry<BundleContentType>> | undefined;
564
+ const processedEntries: Array<BundleEntry<BundleContentType>> | undefined =
565
+ this.config.workspaceTag || this.config.ignoreTags?.length
566
+ ? rawEntries?.map((entry, i) => {
567
+ const req = input.requests[i];
568
+ if (!req || !entry?.resource) return entry;
569
+ if ((req.method === 'GET' || req.method === 'HEAD') && !isBatchSearchUrl(req.url)) {
570
+ try {
571
+ assertRetrievedResource(this.config, entry.resource);
572
+ } catch (err) {
573
+ if (!(err instanceof OystehrFHIRError)) throw err;
574
+ return {
575
+ request: entry.request,
576
+ response: { status: '404', outcome: err.cause },
577
+ } as unknown as BundleEntry<BundleContentType>;
578
+ }
579
+ }
580
+ return entry;
581
+ })
582
+ : rawEntries;
291
583
  const bundle: BatchBundle<BundleContentType> = {
292
584
  ...resp,
293
- entry: resp.entry as Array<BundleEntry<BundleContentType>> | undefined,
585
+ entry: processedEntries,
294
586
  unbundle: function (this: { entry?: Array<BundleEntry<BundleContentType>> | undefined }) {
295
587
  return (
296
588
  this.entry
@@ -317,10 +609,21 @@ export async function transaction<BundleContentType extends FhirResource>(
317
609
  {
318
610
  resourceType: 'Bundle',
319
611
  type: 'transaction',
320
- entry: input.requests.map(batchInputRequestToBundleEntryItem),
612
+ entry: input.requests.map((req) => batchInputRequestToBundleEntryItem(req, this.config)),
321
613
  },
322
614
  request
323
615
  );
616
+ // Validate each GET/HEAD retrieval entry against the tag config.
617
+ // A violation throws OystehrFHIRError(404) — transactions are all-or-nothing.
618
+ if (this.config.workspaceTag || this.config.ignoreTags?.length) {
619
+ (resp.entry as Array<BundleEntry<BundleContentType>> | undefined)?.forEach((entry, i) => {
620
+ const req = input.requests[i];
621
+ if (!req || !entry?.resource) return;
622
+ if ((req.method === 'GET' || req.method === 'HEAD') && !isBatchSearchUrl(req.url)) {
623
+ assertRetrievedResource(this.config, entry.resource);
624
+ }
625
+ });
626
+ }
324
627
  const bundle: TransactionBundle<BundleContentType> = {
325
628
  ...resp,
326
629
  entry: resp.entry as Array<BundleEntry<BundleContentType>> | undefined,
@@ -1,6 +1,7 @@
1
1
  // AUTOGENERATED -- DO NOT EDIT
2
2
 
3
3
  import { OystehrConfig } from '../../config';
4
+ import { OystehrSdkError } from '../../errors';
4
5
  import { Application } from './application';
5
6
  import { Charge } from './charge';
6
7
  import { Conversation } from './conversation';
@@ -51,6 +52,12 @@ export class Oystehr {
51
52
  readonly rcm: Rcm;
52
53
  readonly fhir: Fhir;
53
54
  constructor(config: OystehrConfig) {
55
+ if (config.workspaceTag && config.ignoreTags) {
56
+ throw new OystehrSdkError({
57
+ message: 'workspaceTag and ignoreTags are mutually exclusive and cannot both be set in config',
58
+ code: 400,
59
+ });
60
+ }
54
61
  this.config = config;
55
62
  this.config.services ??= {};
56
63
  this.config.services['projectApiUrl'] ??= config.projectApiUrl;
@@ -4,4 +4,8 @@ export interface RcmListPayersParams {
4
4
  name?: string;
5
5
  identifier?: string;
6
6
  includeInactive?: boolean;
7
+ cursor?: string;
8
+ limit?: number;
9
+ sort?: 'id' | 'name';
10
+ sortOrder?: 'asc' | 'desc';
7
11
  }
@@ -4,4 +4,12 @@ import { Organization } from 'fhir/r4b';
4
4
  /**
5
5
  * List of payers matching the search criteria.
6
6
  */
7
- export type RcmListPayersResponse = Organization[];
7
+ export interface RcmListPayersResponse {
8
+ data: Organization[];
9
+ metadata: {
10
+ /**
11
+ * Cursor to fetch the next page of results. Null if there are no further pages.
12
+ */
13
+ nextCursor: string | null;
14
+ };
15
+ }
@@ -3,15 +3,19 @@ import type {
3
3
  Binary as BinaryR4B,
4
4
  Bundle as BundleR4B,
5
5
  BundleEntry as BundleEntryR4B,
6
+ Coding as CodingR4B,
6
7
  FhirResource as FhirResourceR4B,
7
8
  OperationOutcome as OperationOutcomeR4B,
9
+ Resource as ResourceR4B,
8
10
  } from 'fhir/r4b';
9
11
  import type {
10
12
  Binary as BinaryR5,
11
13
  Bundle as BundleR5,
12
14
  BundleEntry as BundleEntryR5,
15
+ Coding as CodingR5,
13
16
  FhirResource as FhirResourceR5,
14
17
  OperationOutcome as OperationOutcomeR5,
18
+ Resource as ResourceR5,
15
19
  } from 'fhir/r5';
16
20
 
17
21
  export type FhirResource = FhirResourceR4B | FhirResourceR5;
@@ -46,6 +50,8 @@ export type TransactionBundle<F extends FhirResource> = EntrylessFhirBundle<F> &
46
50
  export type BundleEntry<F extends FhirResource> = F extends FhirResourceR4B ? BundleEntryR4B<F> : BundleEntryR5<F>;
47
51
  export type Binary<F extends FhirResource> = F extends FhirResourceR4B ? BinaryR4B : BinaryR5;
48
52
  export type OperationOutcome = OperationOutcomeR4B | OperationOutcomeR5;
53
+ export type Coding = CodingR4B | CodingR5;
54
+ export type Resource = ResourceR4B | ResourceR5;
49
55
 
50
56
  export type SearchParam = { name: string; value: string | number };
51
57
 
@@ -119,6 +125,15 @@ export interface BatchInputPostRequest<F extends FhirResource> extends BatchInpu
119
125
  fullUrl?: string;
120
126
  }
121
127
 
128
+ /**
129
+ * A batch POST to a `_search` endpoint (e.g. `Patient/_search`). Search parameters
130
+ * may be appended to the URL as query parameters. Unlike a create POST there is no
131
+ * resource body.
132
+ */
133
+ export interface BatchInputSearchPostRequest extends BatchInputBaseRequest {
134
+ method: 'POST';
135
+ }
136
+
122
137
  export interface BatchInputBinaryPatchRequest<F extends FhirResource> extends BatchInputBaseRequest {
123
138
  method: 'PATCH';
124
139
  ifMatch?: string;
@@ -141,6 +156,7 @@ export type BatchInputRequest<F extends FhirResource> =
141
156
  | BatchInputPutRequest<F>
142
157
  | BatchInputPatchRequest<F>
143
158
  | BatchInputPostRequest<F>
159
+ | BatchInputSearchPostRequest
144
160
  | BatchInputDeleteRequest;
145
161
 
146
162
  export interface BatchInput<F extends FhirResource> {