@oystehr/sdk 4.3.8 → 4.3.10

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.
Files changed (39) hide show
  1. package/README.md +105 -41
  2. package/dist/cjs/client/client.cjs +137 -5
  3. package/dist/cjs/client/client.cjs.map +1 -1
  4. package/dist/cjs/client/client.d.ts +28 -3
  5. package/dist/cjs/index.min.cjs +1 -1
  6. package/dist/cjs/index.min.cjs.map +1 -1
  7. package/dist/cjs/resources/classes/fhir-ext.cjs +277 -17
  8. package/dist/cjs/resources/classes/fhir-ext.cjs.map +1 -1
  9. package/dist/cjs/resources/classes/fhir-ext.d.ts +114 -12
  10. package/dist/cjs/resources/classes/fhir.cjs +14 -0
  11. package/dist/cjs/resources/classes/fhir.cjs.map +1 -1
  12. package/dist/cjs/resources/classes/fhir.d.ts +14 -0
  13. package/dist/cjs/resources/types/FaxSendParams.d.ts +1 -1
  14. package/dist/cjs/resources/types/ZambdaCreateParams.d.ts +1 -1
  15. package/dist/cjs/resources/types/ZambdaUpdateParams.d.ts +1 -1
  16. package/dist/cjs/resources/types/fhir.d.ts +74 -0
  17. package/dist/esm/client/client.d.ts +28 -3
  18. package/dist/esm/client/client.js +137 -5
  19. package/dist/esm/client/client.js.map +1 -1
  20. package/dist/esm/index.min.js +1 -1
  21. package/dist/esm/index.min.js.map +1 -1
  22. package/dist/esm/resources/classes/fhir-ext.d.ts +114 -12
  23. package/dist/esm/resources/classes/fhir-ext.js +273 -19
  24. package/dist/esm/resources/classes/fhir-ext.js.map +1 -1
  25. package/dist/esm/resources/classes/fhir.d.ts +14 -0
  26. package/dist/esm/resources/classes/fhir.js +15 -1
  27. package/dist/esm/resources/classes/fhir.js.map +1 -1
  28. package/dist/esm/resources/types/FaxSendParams.d.ts +1 -1
  29. package/dist/esm/resources/types/ZambdaCreateParams.d.ts +1 -1
  30. package/dist/esm/resources/types/ZambdaUpdateParams.d.ts +1 -1
  31. package/dist/esm/resources/types/fhir.d.ts +74 -0
  32. package/package.json +1 -1
  33. package/src/client/client.ts +214 -7
  34. package/src/resources/classes/fhir-ext.ts +483 -38
  35. package/src/resources/classes/fhir.ts +14 -0
  36. package/src/resources/types/FaxSendParams.ts +1 -1
  37. package/src/resources/types/ZambdaCreateParams.ts +1 -1
  38. package/src/resources/types/ZambdaUpdateParams.ts +1 -1
  39. package/src/resources/types/fhir.ts +97 -0
@@ -195,13 +195,9 @@ function assertRetrievedResource(config, resource) {
195
195
  }
196
196
  }
197
197
  }
198
- /**
199
- * Performs a FHIR search and returns the results as a Bundle resource
200
- *
201
- * @param options FHIR resource type and FHIR search parameters
202
- * @param request optional OystehrClientRequest object
203
- * @returns FHIR Bundle resource
204
- */
198
+ function isAsyncRequestMode(mode) {
199
+ return mode === 'async-bundle' || mode === 'async-bulk';
200
+ }
205
201
  async function search(params, request) {
206
202
  const { resourceType } = params;
207
203
  const taggedParams = applyTagSearchParams(this.config, params.params);
@@ -215,6 +211,13 @@ async function search(params, request) {
215
211
  return acc;
216
212
  }, {});
217
213
  }
214
+ const requestMode = request?.mode;
215
+ if (isAsyncRequestMode(requestMode)) {
216
+ return await this.startAsyncJob(`/${resourceType}/_search`, 'POST', paramMap ?? {}, requestMode, {
217
+ ...request,
218
+ contentType: 'application/x-www-form-urlencoded',
219
+ });
220
+ }
218
221
  const requestBundle = await this.fhirRequest(`/${resourceType}/_search`, 'POST')(paramMap, {
219
222
  ...request,
220
223
  contentType: 'application/x-www-form-urlencoded',
@@ -228,12 +231,61 @@ async function search(params, request) {
228
231
  };
229
232
  return bundle;
230
233
  }
234
+ /**
235
+ * Performs an iterative FHIR search over initial request and following "next" urls,
236
+ * collecting all pages into a single Bundle.
237
+ *
238
+ * @param params FHIR search parameters plus optional pageSize that will overwrite _count in params
239
+ * @param request optional OystehrClientRequest object
240
+ * @returns FHIR Bundle resource that contains all entries across all pages. Bundle-level metadata
241
+ * (id, meta, total, etc.) is taken from the first page.
242
+ */
243
+ async function searchAndGetAllPages(params, request) {
244
+ const { pageSize, ...searchParams } = params;
245
+ let firstPageParams = searchParams;
246
+ if (pageSize) {
247
+ const baseParams = (searchParams.params ?? []).filter((p) => p.name !== '_count') ?? [];
248
+ firstPageParams = { ...searchParams, params: [...baseParams, { name: '_count', value: pageSize }] };
249
+ }
250
+ const allEntries = [];
251
+ const typedSearch = (search);
252
+ // search returns Bundle, and fhirRequest in the while block returns FhirBundle
253
+ let currentBundle = await typedSearch.call(this, firstPageParams, request);
254
+ const firstBundle = { ...currentBundle, link: currentBundle.link?.filter((link) => link.relation !== 'next') };
255
+ // eslint-disable-next-line no-constant-condition
256
+ while (true) {
257
+ const entries = currentBundle.entry;
258
+ if (entries) {
259
+ allEntries.push(...entries);
260
+ }
261
+ const nextLink = currentBundle.link?.find((link) => link.relation === 'next')?.url;
262
+ if (!nextLink) {
263
+ break;
264
+ }
265
+ currentBundle = await this.fhirRequest(nextLink, 'GET')({}, request);
266
+ }
267
+ return {
268
+ ...firstBundle,
269
+ entry: allEntries.length ? allEntries : undefined,
270
+ unbundle: function () {
271
+ return this.entry?.map((e) => e.resource).filter((r) => r !== undefined) ?? [];
272
+ },
273
+ };
274
+ }
231
275
  async function create(params, request) {
232
276
  const tagged = applyTagToResource(this.config, params);
233
277
  const { resourceType } = tagged;
234
- return this.fhirRequest(`/${resourceType}`, 'POST')(tagged, request);
278
+ const requestMode = request?.mode;
279
+ if (isAsyncRequestMode(requestMode)) {
280
+ return await this.startAsyncJob(`/${resourceType}`, 'POST', tagged, requestMode, request);
281
+ }
282
+ return await this.fhirRequest(`/${resourceType}`, 'POST')(tagged, request);
235
283
  }
236
284
  async function get({ resourceType, id }, request) {
285
+ const requestMode = request?.mode;
286
+ if (isAsyncRequestMode(requestMode)) {
287
+ return await this.startAsyncJob(`/${resourceType}/${id}`, 'GET', {}, requestMode, request);
288
+ }
237
289
  const result = await this.fhirRequest(`/${resourceType}/${id}`, 'GET')({}, request);
238
290
  assertRetrievedResource(this.config, result);
239
291
  return result;
@@ -241,23 +293,215 @@ async function get({ resourceType, id }, request) {
241
293
  async function update(params, request) {
242
294
  const tagged = applyTagToResource(this.config, params);
243
295
  const { id, resourceType } = tagged;
244
- return this.fhirRequest(`/${resourceType}/${id}`, 'PUT')(tagged, {
296
+ const requestMode = request?.mode;
297
+ const ifMatchRequest = {
245
298
  ...request,
246
299
  ifMatch: request?.optimisticLockingVersionId ? `W/"${request.optimisticLockingVersionId}"` : undefined,
247
- });
300
+ };
301
+ if (isAsyncRequestMode(requestMode)) {
302
+ return await this.startAsyncJob(`/${resourceType}/${id}`, 'PUT', tagged, requestMode, ifMatchRequest);
303
+ }
304
+ return await this.fhirRequest(`/${resourceType}/${id}`, 'PUT')(tagged, ifMatchRequest);
248
305
  }
249
306
  async function patch({ resourceType, id, operations }, request) {
250
307
  const taggedOperations = applyTagToPatchOperations(this.config, operations);
251
- return this.fhirRequest(`/${resourceType}/${id}`, 'PATCH')(taggedOperations, {
308
+ const requestMode = request?.mode;
309
+ const ifMatchRequest = {
252
310
  ...request,
253
- contentType: 'application/json-patch+json',
254
311
  ifMatch: request?.optimisticLockingVersionId ? `W/"${request.optimisticLockingVersionId}"` : undefined,
312
+ };
313
+ if (isAsyncRequestMode(requestMode)) {
314
+ return await this.startAsyncJob(`/${resourceType}/${id}`, 'PATCH', taggedOperations, requestMode, {
315
+ ...ifMatchRequest,
316
+ contentType: 'application/json-patch+json',
317
+ });
318
+ }
319
+ return this.fhirRequest(`/${resourceType}/${id}`, 'PATCH')(taggedOperations, {
320
+ ...ifMatchRequest,
321
+ contentType: 'application/json-patch+json',
255
322
  });
256
323
  }
257
324
  async function del({ resourceType, id }, request) {
258
- return this.fhirRequest(`/${resourceType}/${id}`, 'DELETE')({}, request);
325
+ const requestMode = request?.mode;
326
+ if (isAsyncRequestMode(requestMode)) {
327
+ return await this.startAsyncJob(`/${resourceType}/${id}`, 'DELETE', {}, requestMode, request);
328
+ }
329
+ return await this.fhirRequest(`/${resourceType}/${id}`, 'DELETE')({}, request);
330
+ }
331
+ function getRetryDelayMs(retryAfter, fallbackMs) {
332
+ if (!retryAfter) {
333
+ return fallbackMs;
334
+ }
335
+ const asSeconds = Number(retryAfter);
336
+ if (Number.isFinite(asSeconds) && asSeconds >= 0) {
337
+ return Math.max(0, Math.floor(asSeconds * 1000));
338
+ }
339
+ const asTimestamp = Date.parse(retryAfter);
340
+ if (Number.isFinite(asTimestamp)) {
341
+ return Math.max(0, asTimestamp - Date.now());
342
+ }
343
+ return fallbackMs;
344
+ }
345
+ /**
346
+ * Fetches the status of an async job. If the job is still in progress, returns an object with status 202. If the job is completed, returns the job result with status 200. If the job has failed, returns an object with status 500 and an OperationOutcome resource describing the failure. If the job has expired, returns an object with status 410.
347
+ * @param this The SDKResource context (this is an extension method and should be called with the SDKResource instance as the context, e.g. `sdkResource.getAsyncJob(jobId)`)
348
+ * @param jobId The ID of the async job to fetch
349
+ * @param request Optional OystehrClientRequest for authentication and headers
350
+ * @returns A Promise that resolves to the FhirAsyncJobStatus
351
+ */
352
+ async function getAsyncJob(jobId, request) {
353
+ return await this.fetchAsyncJobStatus(jobId, request);
354
+ }
355
+ /**
356
+ * Waits for an async job to complete by polling its status until it reaches a terminal state (success, failure, or expiration) or the specified timeout is reached. Returns the final job status. Throws if the job fails, expires, or does not complete within the timeout.
357
+ * @param this The SDKResource context (this is an extension method and should be called with the SDKResource instance as the context, e.g. `sdkResource.waitForAsyncJob(jobId)`)
358
+ * @param jobId The ID of the async job to wait for
359
+ * @param options Optional FhirAsyncWaitOptions to configure polling behavior
360
+ * @param request Optional OystehrClientRequest for authentication and headers
361
+ * @returns A Promise that resolves to the final FhirAsyncJobStatus
362
+ */
363
+ async function waitForAsyncJob(jobId, options, request) {
364
+ // 5 seconds poll interval by default
365
+ const pollIntervalMs = options?.pollIntervalMs ?? 5000;
366
+ // 15 minutes timout by default
367
+ const timeoutMs = options?.timeoutMs ?? 900000;
368
+ const attempts = Math.max(1, Math.ceil(timeoutMs / pollIntervalMs));
369
+ for (let attempt = 0; attempt < attempts; attempt++) {
370
+ const status = await this.fetchAsyncJobStatus(jobId, request);
371
+ if (status.status !== 202) {
372
+ return status;
373
+ }
374
+ if (attempt < attempts - 1) {
375
+ const retryAfter = 'retryAfter' in status ? status.retryAfter : undefined;
376
+ await new Promise((resolve) => setTimeout(resolve, getRetryDelayMs(retryAfter, pollIntervalMs)));
377
+ }
378
+ }
379
+ throw new index.OystehrSdkError({
380
+ message: `Async job ${jobId} did not complete within ${timeoutMs} ms`,
381
+ code: 408,
382
+ });
383
+ }
384
+ function parseNdjsonResources(ndjson, sourceUrl) {
385
+ const resources = [];
386
+ const lines = ndjson.split('\n');
387
+ for (let index$1 = 0; index$1 < lines.length; index$1++) {
388
+ const line = lines[index$1].trim();
389
+ if (line.length === 0) {
390
+ continue;
391
+ }
392
+ try {
393
+ resources.push(JSON.parse(line));
394
+ }
395
+ catch (error) {
396
+ throw new index.OystehrSdkError({
397
+ message: `Failed to parse NDJSON line ${index$1 + 1} from ${sourceUrl}`,
398
+ code: 500,
399
+ cause: error,
400
+ });
401
+ }
402
+ }
403
+ return resources;
404
+ }
405
+ /**
406
+ * Waits for an async job to complete and retrieves the bulk output manifest and files. Throws if the job fails, expires, or does not complete within the specified timeout.
407
+ * @param this The SDKResource context (this is an extension method and should be called with the SDKResource instance as the context, e.g. `sdkResource.waitForAsyncBulkOutput(jobId)`)
408
+ * @param jobId The ID of the async job to wait for
409
+ * @param options Optional FhirAsyncWaitOptions to configure polling behavior
410
+ * @param request Optional OystehrClientRequest for authentication and headers
411
+ * @returns A Promise that resolves to a FhirAsyncBulkOutputResult containing the bulk output manifest and files
412
+ */
413
+ async function waitForAsyncBulkOutput(jobId, options, request) {
414
+ const status = await waitForAsyncJob.call(this, jobId, options, request);
415
+ if (status.status === 404) {
416
+ throw new index.OystehrSdkError({
417
+ message: `Async job ${jobId} not found`,
418
+ code: 404,
419
+ });
420
+ }
421
+ if (status.status === 410) {
422
+ throw new index.OystehrSdkError({
423
+ message: `Async job ${jobId} expired`,
424
+ code: 410,
425
+ });
426
+ }
427
+ if (status.status !== 200 || !('mode' in status) || status.mode !== 'bulk') {
428
+ throw new index.OystehrSdkError({
429
+ message: `Async job ${jobId} did not complete in bulk mode`,
430
+ code: status.status,
431
+ });
432
+ }
433
+ const accessToken = request?.accessToken ?? this.config.accessToken;
434
+ const projectId = request?.projectId ?? this.config.projectId;
435
+ if (status.manifest.requiresAccessToken && !accessToken) {
436
+ throw new index.OystehrSdkError({
437
+ message: `Bulk output for async job ${jobId} requires an access token`,
438
+ code: 401,
439
+ });
440
+ }
441
+ const fetchImpl = this.config.fetch ?? fetch;
442
+ const headers = {};
443
+ if (projectId) {
444
+ headers['x-zapehr-project-id'] = projectId;
445
+ headers['x-oystehr-project-id'] = projectId;
446
+ }
447
+ if (status.manifest.requiresAccessToken && accessToken) {
448
+ headers.Authorization = `Bearer ${accessToken}`;
449
+ }
450
+ const requestHeaders = Object.keys(headers).length > 0 ? headers : undefined;
451
+ const output = await Promise.all(status.manifest.output.map(async (file) => {
452
+ const response = await fetchImpl(new Request(file.url, {
453
+ method: 'GET',
454
+ headers: requestHeaders,
455
+ }));
456
+ if (!response.ok) {
457
+ throw new index.OystehrSdkError({
458
+ message: `Failed to download bulk output (${file.type}): HTTP ${response.status}`,
459
+ code: response.status,
460
+ });
461
+ }
462
+ const ndjson = await response.text();
463
+ return {
464
+ ...file,
465
+ resources: parseNdjsonResources(ndjson, file.url),
466
+ };
467
+ }));
468
+ return {
469
+ manifest: status.manifest,
470
+ output,
471
+ };
472
+ }
473
+ /**
474
+ * Wrapper around waitForAsyncBulkOutput that transforms the retrieved bulk output files into a single Bundle resource containing all the output resources as entries. This is a convenience method for use cases where you want to work with the bulk output as a Bundle, but it may not be efficient for large outputs due to the overhead of downloading and parsing all files and constructing the Bundle in memory.
475
+ * Can be slow due to downloading and parsing potentially large NDJSON files, so use only if you need the full output as a Bundle resource. For more efficient processing of large bulk outputs, use waitForAsyncBulkOutput directly.
476
+ * @param jobId the ID of the async job to wait for
477
+ * @param options optional FhirAsyncWaitOptions to configure polling behavior
478
+ * @param request optional OystehrClientRequest for authentication and headers
479
+ * @returns a Promise that resolves to a Bundle containing all resources from the bulk output
480
+ */
481
+ async function waitForAsyncBulkBundle(jobId, options, request) {
482
+ const bulkOutput = await waitForAsyncBulkOutput.call(this, jobId, options, request);
483
+ const resources = bulkOutput.output.flatMap((file) => file.resources);
484
+ const bundle = {
485
+ resourceType: 'Bundle',
486
+ type: 'collection',
487
+ entry: resources.map((resource) => ({ resource })),
488
+ unbundle: function () {
489
+ return this.entry?.map((entry) => entry.resource).filter((value) => value !== undefined) ?? [];
490
+ },
491
+ };
492
+ return bundle;
493
+ }
494
+ async function cancelAsyncJob(jobId, request) {
495
+ await this.fhirRequest(`/async-job/${jobId}`, 'DELETE')({}, request);
259
496
  }
260
497
  async function history({ resourceType, id, versionId, count, offset, }, request) {
498
+ const requestMode = request?.mode;
499
+ if (isAsyncRequestMode(requestMode)) {
500
+ if (versionId) {
501
+ return await this.startAsyncJob(`/${resourceType}/${id}/_history/${versionId}`, 'GET', {}, requestMode, request);
502
+ }
503
+ return await this.startAsyncJob(`/${resourceType}/${id}/_history`, 'GET', {}, requestMode, request);
504
+ }
261
505
  if (versionId) {
262
506
  return this.fhirRequest(`/${resourceType}/${id}/_history/${versionId}`, 'GET')({}, request);
263
507
  }
@@ -401,11 +645,16 @@ function batchInputRequestToBundleEntryItem(request, config) {
401
645
  throw new Error('Unrecognized method');
402
646
  }
403
647
  async function batch(input, request) {
404
- const resp = await this.fhirRequest('/', 'POST')({
648
+ const requestPayload = {
405
649
  resourceType: 'Bundle',
406
650
  type: 'batch',
407
651
  entry: input.requests.map((req) => batchInputRequestToBundleEntryItem(req, this.config)),
408
- }, request);
652
+ };
653
+ const requestMode = request?.mode;
654
+ if (isAsyncRequestMode(requestMode)) {
655
+ return await this.startAsyncJob('/', 'POST', requestPayload, requestMode, request);
656
+ }
657
+ const resp = await this.fhirRequest('/', 'POST')(requestPayload, request);
409
658
  // Validate each GET/HEAD retrieval entry against the tag config.
410
659
  // Violations are replaced with a synthetic 404 OperationOutcome entry; batch entries are independent.
411
660
  const rawEntries = resp.entry;
@@ -448,11 +697,16 @@ async function batch(input, request) {
448
697
  return bundle;
449
698
  }
450
699
  async function transaction(input, request) {
451
- const resp = await this.fhirRequest('/', 'POST')({
700
+ const requestPayload = {
452
701
  resourceType: 'Bundle',
453
702
  type: 'transaction',
454
703
  entry: input.requests.map((req) => batchInputRequestToBundleEntryItem(req, this.config)),
455
- }, request);
704
+ };
705
+ const requestMode = request?.mode;
706
+ if (isAsyncRequestMode(requestMode)) {
707
+ return await this.startAsyncJob('/', 'POST', requestPayload, requestMode, request);
708
+ }
709
+ const resp = await this.fhirRequest('/', 'POST')(requestPayload, request);
456
710
  // Validate each GET/HEAD retrieval entry against the tag config.
457
711
  // A violation throws OystehrFHIRError(404) — transactions are all-or-nothing.
458
712
  if (this.config.workspaceTag || this.config.ignoreTags?.length) {
@@ -523,15 +777,21 @@ function formatHumanName(name, options) {
523
777
  }
524
778
 
525
779
  exports.batch = batch;
780
+ exports.cancelAsyncJob = cancelAsyncJob;
526
781
  exports.create = create;
527
782
  exports.delete = del;
528
783
  exports.formatAddress = formatAddress;
529
784
  exports.formatHumanName = formatHumanName;
530
785
  exports.generateFriendlyPatientId = generateFriendlyPatientId;
531
786
  exports.get = get;
787
+ exports.getAsyncJob = getAsyncJob;
532
788
  exports.history = history;
533
789
  exports.patch = patch;
534
790
  exports.search = search;
791
+ exports.searchAndGetAllPages = searchAndGetAllPages;
535
792
  exports.transaction = transaction;
536
793
  exports.update = update;
794
+ exports.waitForAsyncBulkBundle = waitForAsyncBulkBundle;
795
+ exports.waitForAsyncBulkOutput = waitForAsyncBulkOutput;
796
+ exports.waitForAsyncJob = waitForAsyncJob;
537
797
  //# sourceMappingURL=fhir-ext.cjs.map