diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index eba6033..eba42bb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,5 +17,32 @@ services: timeout: 5s retries: 5 + # --- Integration-test services (only start with: docker compose --profile test up) --- + # Back the engine's connector integration lane (packages/engine *.itest.ts). + # Match the env the gates expect (packages/engine/src/__tests__/integration/gates.ts). + + sftp-test: + image: atmoz/sftp:alpine + container_name: mirthless-sftp-test + profiles: ["test"] + # user:password:::dir → creates /home/mirth/upload, writable, chrooted. + command: mirth:mirthpw:::upload + ports: + - "2222:22" + + mail-test: + # GreenMail: SMTP (3025) + IMAP (3143) in one container for mail connectors. + image: greenmail/standalone:2.1.0 + container_name: mirthless-mail-test + profiles: ["test"] + environment: + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.all + -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.users=mirth:mirthpw@example.com + ports: + - "3025:3025" + - "3143:3143" + volumes: postgres_data: diff --git a/docs/progress/CHANGELOG.md b/docs/progress/CHANGELOG.md index 580e91d..35fdb6b 100644 --- a/docs/progress/CHANGELOG.md +++ b/docs/progress/CHANGELOG.md @@ -2,6 +2,33 @@ > Session-by-session log of what was built. Enables any future Claude instance to pick up where we left off. +## 2026-07-14 — Real-message E2E testing + DICOM dcmjs-dimse port (branch `feature/real-e2e-testing`) + +Replaced mock-heavy connector tests with a harness that actually pushes messages through real +connectors and asserts what lands on the other side. **All 11 connector types now covered by a +passing real-message E2E.** + +- **E2E harness** (`packages/engine/src/__tests__/support/`) — `deployChannel(spec)` assembles a + live channel (sandbox + 8-stage pipeline + real source/dest connectors) over an in-memory store; + filter/transformer/response-transformer scripts compile through the real esbuild+template path. + `CaptureDestination` sink + MLLP TCP helpers. Two lanes: default `pnpm test` (no infra) and + `test:integration` (`*.itest.ts`, env-gated via `integration/gates.ts`; docker `--profile test` + adds atmoz/sftp + GreenMail). +- **Coverage**: TCP/MLLP + Channel (A→B→C HL7→JSON→XML cascade), File, HTTP, JavaScript (src+dest), + Database (real `_test` PG), SFTP (listen↔dest cascade), SMTP+IMAP (GreenMail cascade), FHIR (dest), + DICOM (SCU→SCP cascade). +- **DICOM connector ported to `dcmjs-dimse`** (pure JS) from `@ubercode/dcmtk` (native binaries). + Root cause of the prior association rejection: the dcmtk wrapper created the SCP with no accepted + presentation contexts. New `dcmjs-dimse-adapter.ts` negotiates contexts (accept all StorageClass + SOP classes + Verification + common transfer syntaxes) — pattern lifted from the production + MedFusion DIMSE service. Drop-in behind the existing `DcmtkReceiver`/`DcmtkSender` factory seams; + no connector API change. DICOM cascade now runs in the default lane, in-process, no binaries. (D-181) +- **TypeScript channel scripts** compiled+run end-to-end; shipped ambient types + `packages/engine/sandbox-globals.d.ts` (drift-guarded against the web Monaco string). +- **Bug fix** (`4c6af8f`): `ChannelService.create` silently dropped `input.transformers`/`filters` + (only `update` persisted them) — data loss on clone/import/programmatic create. Extracted shared + `syncFilters`/`syncTransformers`, wired into both paths; real-Postgres regression test. + ## 2026-07-13 — Dashboard/Channels/Messages overhaul (branch `feature/dashboard-overhaul`) Reworked the three main views so everything happens from the Dashboard; the standalone diff --git a/docs/progress/DECISIONS.md b/docs/progress/DECISIONS.md index 2b0d9e7..e23f854 100644 --- a/docs/progress/DECISIONS.md +++ b/docs/progress/DECISIONS.md @@ -357,3 +357,5 @@ D-178: dbQuery script bridge = named Data Sources, not script-supplied connectio D-179: Fixed migration `when`-timestamp ordering so drizzle-kit applies 0009/0010 (and future migrations). Root cause: the hand-written migrations 0007/0008 were registered in `meta/_journal.json` with future-dated round `when` values (1784000000000 / 1784000100000 ≈ 2026-07-14), while 0009 (collections) and 0010 (data_sources) were generated 2026-07-13 with real lower `Date.now()` values. `drizzle-kit migrate` applies journal entries in `when` order and skips any whose `when` ≤ the last-applied — so once 0008 was applied, 0009/0010 were silently skipped (migrate reported "success" as a no-op). Effect: a **fresh install** created neither `collections` nor `data_sources`, and the dev DB was missing `data_sources` (the Data Sources page 500'd on "relation does not exist"). Fix: bumped 0009→1784000200000 and 0010→1784000300000 in the journal so all `when`s are monotonic by idx (verified: a from-scratch `drizzle-kit migrate` now creates collections + data_sources). Reconciled the dev DB by updating the already-applied 0009 record's `created_at` to match, then `db:migrate` applied 0010 cleanly. LESSON: never hand-set a migration's journal `when` to a future timestamp — a later generated migration will get a lower real timestamp and be skipped. Since it is now past those dates, newly generated migrations sort correctly again; this was a one-time skew affecting only 0009/0010. — 2026-07-14 (branch fix/migration-timestamp-ordering) D-180: RBAC now resolves permissions LIVE from the user's role (single source of truth), replacing the per-user user_permissions snapshot. Root cause of the recurring bug (new permissions like collections:*/datasources:* never reaching existing users, requiring a re-seed): permissions were snapshotted into user_permissions only at user create / role-change, but the enforcement/login paths READ that stale snapshot. Meanwhile UserService.getPermissions already resolved live — two divergent truths. Fix: all four readers (auth.middleware authenticate + optionalAuthenticate, auth.service login, socket.ts) now call `permissionNamesForRole(user.role)` (the same resolver getPermissions uses); removed the dead write path `syncPermissionsForRole` from UserService (create/update no longer touch user_permissions). Effect: adding a permission to a role in code reaches every existing user of that role on their next request — no re-seed, no re-login for server enforcement. The `user_permissions` table is now vestigial (kept to avoid a migration this pass; slated for a drop-migration + scope-column cleanup in RBAC Round 2). Chosen over snapshot+reconcile-on-login (user's call) because one source of truth eliminates the whole staleness class. This is the P0 security/correctness fix; admin-managed role CRUD (roles-as-data) and sub-resource ACLs are deferred to RBAC Round 2 per D-audit plan. — 2026-07-14 (branch feature/quality-pass-1) + +D-181: DICOM connector runs on `dcmjs-dimse` (pure JS), not `@ubercode/dcmtk` (native binaries). Root cause of the DICOM SCP rejecting every C-STORE association ("Rejected Permanent, Source: Service User"): the dcmtk wrapper (`defaultReceiverFactory`) called `DicomReceiver.create` with NO accepted presentation contexts, so the SCP could not negotiate a context for the sender's proposed SOP class + transfer syntax. A DICOM SCP MUST inspect `association.getPresentationContexts()` and `setResult(Accept, transferSyntax)` for the ones it supports before `sendAssociationAccept()`. The native wrapper gave us no seam to do that; it also spawned processes (Windows path issues, not testable in-process). Decision: swap to `dcmjs-dimse` (the same library the production MedFusion DIMSE service uses at `vns/medfusion`), implementing a `DimseReceiver`/`DimseSender` adapter (`packages/connectors/src/dicom/dcmjs-dimse-adapter.ts`) that negotiates presentation contexts (accept all `StorageClass` SOP classes + Verification + common transfer syntaxes, mirroring MedFusion's `presentation-contexts.ts`/`scp.ts`), writes each received instance to a `.dcm` via `Dataset.toFile`, and sends via `Client`+`CStoreRequest(filePath)`. The swap is a drop-in behind the existing injectable `DcmtkReceiver`/`DcmtkSender` factory interfaces (D-086) — no connector API/config change, existing mock-injecting unit tests untouched. Result: the DICOM SCU→SCP cascade now passes in-process in the DEFAULT test lane (no native binaries, no external SCP), completing 11/11 connectors under real-message E2E. `dcmjs-dimse`'s loglevel is set to `warn` to suppress per-association protocol chatter. Chosen over patching dcmtk (we don't control its create() options; still native + spawn) or leaving DICOM as a documented reproducer (10/11). — 2026-07-14 (branch feature/real-e2e-testing) diff --git a/packages/connectors/package.json b/packages/connectors/package.json index 0fa6585..6fe4b40 100644 --- a/packages/connectors/package.json +++ b/packages/connectors/package.json @@ -23,7 +23,7 @@ "@mirthless/core-models": "workspace:*", "@mirthless/core-util": "workspace:*", "@mirthless/engine": "workspace:*", - "@ubercode/dcmtk": "^0.3.0", + "dcmjs-dimse": "^0.3.2", "generic-pool": "^3.9.0", "imapflow": "^1.0.171", "nodemailer": "^9.0.1", diff --git a/packages/connectors/src/dicom/dcmjs-dimse-adapter.ts b/packages/connectors/src/dicom/dcmjs-dimse-adapter.ts new file mode 100644 index 0000000..1213d06 --- /dev/null +++ b/packages/connectors/src/dicom/dcmjs-dimse-adapter.ts @@ -0,0 +1,310 @@ +// =========================================== +// dcmjs-dimse adapter for the DICOM connector +// =========================================== +// Pure-JS DICOM DIMSE backing for the DICOM source (SCP / C-STORE receive) and +// destination (SCU / C-STORE send), replacing the native @ubercode/dcmtk wrapper. +// +// The critical piece the native wrapper lacked is association negotiation: an SCP +// must inspect the requested presentation contexts and ACCEPT the ones whose +// abstract syntax (SOP class) and a transfer syntax it supports, otherwise the +// association is rejected ("Source: Service User"). The accept-list here mirrors +// the production MedFusion DIMSE service (packages/dicom + apps/dimse). + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Result } from '@mirthless/core-util'; +import pkg from 'dcmjs-dimse'; +import type { DcmtkReceiver, DcmtkFileData, DcmtkAssociationData } from './dicom-receiver.js'; +import type { DcmtkSender, DcmtkSendResult } from './dicom-dispatcher.js'; + +const { Server, Scp, Client, Dataset, constants, requests, responses, log } = pkg; +const { SopClass, StorageClass, TransferSyntax, PresentationContextResult, Status } = constants; +const { CStoreRequest } = requests; +const { CEchoResponse, CStoreResponse } = responses; + +// Silence dcmjs-dimse's per-association protocol chatter (loglevel INFO); the +// connector surfaces failures through its own ConnectorLogger. Warnings/errors +// from the library still pass through. +log.setLevel('warn'); + +type AssociationType = InstanceType; +type CStoreRequestType = InstanceType; +type CStoreResponseType = InstanceType; +type CEchoRequestType = InstanceType; +type CEchoResponseType = InstanceType; +type DatasetType = InstanceType; +type ServerType = InstanceType; + +function ok(value: T): Result { + return { ok: true, value, error: null } as Result; +} +function fail(error: Error): Result { + return { ok: false, value: null, error } as unknown as Result; +} + +// ----- Accepted presentation contexts ----- + +const ACCEPTED_TRANSFER_SYNTAXES: ReadonlySet = new Set([ + TransferSyntax.ImplicitVRLittleEndian, + TransferSyntax.ExplicitVRLittleEndian, + TransferSyntax.ExplicitVRBigEndian, + TransferSyntax.DeflatedExplicitVRLittleEndian, + TransferSyntax.RleLossless, + TransferSyntax.JpegBaseline, + TransferSyntax.JpegLossless, + TransferSyntax.JpegLsLossless, + TransferSyntax.JpegLsLossy, + TransferSyntax.Jpeg2000Lossless, + TransferSyntax.Jpeg2000Lossy, +]); + +// All storage SOP classes plus Verification (C-ECHO). The channel connector only +// stores, so we accept everything storable rather than a narrow modality list. +const ACCEPTED_ABSTRACT_SYNTAXES: ReadonlySet = new Set([ + SopClass.Verification, + ...Object.values(StorageClass) as string[], +]); + +/** Negotiate presentation contexts on an inbound association: accept storable SOP classes. */ +function negotiatePresentationContexts(association: AssociationType): void { + const contexts = association.getPresentationContexts(); + for (const { id } of contexts) { + const context = association.getPresentationContext(id); + const abstractSyntax = context.getAbstractSyntaxUid(); + if (!ACCEPTED_ABSTRACT_SYNTAXES.has(abstractSyntax)) { + context.setResult(PresentationContextResult.RejectAbstractSyntaxNotSupported); + continue; + } + let accepted = false; + for (const ts of context.getTransferSyntaxUids()) { + if (ACCEPTED_TRANSFER_SYNTAXES.has(ts)) { + context.setResult(PresentationContextResult.Accept, ts); + accepted = true; + break; + } + } + if (!accepted) { + context.setResult(PresentationContextResult.RejectTransferSyntaxesNotSupported); + } + } +} + +// ----- SCP receiver ----- + +interface ReceiverOptions { + readonly port: number; + readonly storageDir: string; + readonly aeTitle: string; + readonly connectionTimeoutMs: number; +} + +class DimseReceiver implements DcmtkReceiver { + private server: ServerType | null = null; + private fileListener: ((data: DcmtkFileData) => void) | null = null; + private assocListener: ((data: DcmtkAssociationData) => void) | null = null; + private errorListener: ((data: { readonly error: Error }) => void) | null = null; + + constructor(private readonly options: ReceiverOptions) {} + + onFileReceived(listener: (data: DcmtkFileData) => void): void { this.fileListener = listener; } + onAssociationComplete(listener: (data: DcmtkAssociationData) => void): void { this.assocListener = listener; } + onEvent(_event: 'error', listener: (data: { readonly error: Error }) => void): void { this.errorListener = listener; } + + /** Persist a received dataset to a .dcm file and notify listeners. */ + private handleInstance(dataset: DatasetType, callingAe: string, calledAe: string, assocId: string, files: string[]): void { + const elements = dataset.getElements() as Record; + const sopInstanceUid = typeof elements['SOPInstanceUID'] === 'string' && elements['SOPInstanceUID'] + ? (elements['SOPInstanceUID'] as string) + : Dataset.generateDerivedUid(); + fs.mkdirSync(this.options.storageDir, { recursive: true }); + const filePath = path.join(this.options.storageDir, `${sopInstanceUid}.dcm`); + dataset.toFile(filePath); + files.push(filePath); + this.fileListener?.({ + filePath, + associationId: assocId, + associationDir: this.options.storageDir, + callingAE: callingAe, + calledAE: calledAe, + source: 'dcmjs-dimse', + instance: { dataset: elements }, + }); + } + + async start(): Promise> { + // Capture just what the per-connection Scp needs, to avoid aliasing `this`. + const storageDir = this.options.storageDir; + const handleInstance = this.handleInstance.bind(this); + const emitAssociation = (data: DcmtkAssociationData): void => { this.assocListener?.(data); }; + const emitError = (error: Error): void => { this.errorListener?.({ error }); }; + let assocCounter = 0; + + class ConnectorScp extends Scp { + private callingAe = ''; + private calledAe = ''; + private readonly assocId = `assoc-${String(++assocCounter)}`; + private readonly files: string[] = []; + private readonly startedAt = Date.now(); + + override associationRequested(association: AssociationType): void { + this.callingAe = association.getCallingAeTitle(); + this.calledAe = association.getCalledAeTitle(); + negotiatePresentationContexts(association); + this.sendAssociationAccept(); + } + + override associationReleaseRequested(): void { + emitAssociation({ + associationId: this.assocId, + associationDir: storageDir, + callingAE: this.callingAe, + calledAE: this.calledAe, + source: 'dcmjs-dimse', + files: [...this.files], + durationMs: Date.now() - this.startedAt, + }); + this.sendAssociationReleaseResponse(); + } + + override cEchoRequest(request: CEchoRequestType, callback: (response: CEchoResponseType) => void): void { + const response = CEchoResponse.fromRequest(request); + response.setStatus(Status.Success); + callback(response); + } + + override cStoreRequest(request: CStoreRequestType, callback: (response: CStoreResponseType) => void): void { + const response = CStoreResponse.fromRequest(request); + try { + const dataset = request.getDataset(); + if (!dataset) { + response.setStatus(Status.ProcessingFailure); + callback(response); + return; + } + handleInstance(dataset, this.callingAe, this.calledAe, this.assocId, this.files); + response.setStatus(Status.Success); + } catch (err) { + emitError(err instanceof Error ? err : new Error(String(err))); + response.setStatus(Status.ProcessingFailure); + } + callback(response); + } + } + + try { + const server = new Server(ConnectorScp as unknown as typeof Scp); + server.on('networkError', (e: Error) => { this.errorListener?.({ error: e }); }); + server.listen(this.options.port, { + connectTimeout: this.options.connectionTimeoutMs, + associationTimeout: this.options.connectionTimeoutMs, + pduTimeout: this.options.connectionTimeoutMs, + }); + this.server = server; + return ok(undefined); + } catch (err) { + return fail(err instanceof Error ? err : new Error(String(err))); + } + } + + async stop(): Promise { + this.server?.close(); + this.server = null; + } +} + +/** Factory: a dcmjs-dimse-backed DICOM SCP receiver. */ +export function createDimseReceiver(options: { + readonly port: number; + readonly storageDir: string; + readonly aeTitle: string; + readonly minPoolSize: number; + readonly maxPoolSize: number; + readonly connectionTimeoutMs: number; +}): Result { + return ok(new DimseReceiver({ + port: options.port, + storageDir: options.storageDir, + aeTitle: options.aeTitle, + connectionTimeoutMs: options.connectionTimeoutMs, + })); +} + +// ----- SCU sender ----- + +interface SenderOptions { + readonly host: string; + readonly port: number; + readonly calledAETitle: string; + readonly callingAETitle: string; + readonly maxRetries: number; + readonly retryDelayMs: number; + readonly timeoutMs: number; +} + +class DimseSender implements DcmtkSender { + constructor(private readonly options: SenderOptions) {} + + private sendOnce(files: readonly string[]): Promise> { + return new Promise((resolve) => { + const startedAt = Date.now(); + const client = new Client(); + for (const file of files) { + client.addRequest(new CStoreRequest(file)); + } + + let rejection: Error | null = null; + client.on('associationRejected', (r: { result: number; source: number; reason: number }) => { + rejection = new Error(`DICOM association rejected: result=${String(r.result)} source=${String(r.source)} reason=${String(r.reason)}`); + }); + client.on('networkError', (e: Error) => { resolve(fail(e)); }); + client.on('closed', () => { + if (rejection) { resolve(fail(rejection)); return; } + resolve(ok({ files, fileCount: files.length, durationMs: Date.now() - startedAt })); + }); + + client.send(this.options.host, this.options.port, this.options.callingAETitle, this.options.calledAETitle, { + connectTimeout: this.options.timeoutMs, + associationTimeout: this.options.timeoutMs, + pduTimeout: this.options.timeoutMs, + }); + }); + } + + async send(files: readonly string[]): Promise> { + let attempt = 0; + let last: Result = fail(new Error('no attempt made')); + for (;;) { + last = await this.sendOnce(files); + if (last.ok || attempt >= this.options.maxRetries) return last; + attempt += 1; + if (this.options.retryDelayMs > 0) { + await new Promise((r) => setTimeout(r, this.options.retryDelayMs)); + } + } + } + + async stop(): Promise { /* clients are created per send */ } +} + +/** Factory: a dcmjs-dimse-backed DICOM SCU sender. */ +export function createDimseSender(options: { + readonly host: string; + readonly port: number; + readonly calledAETitle: string; + readonly callingAETitle: string; + readonly mode: 'single' | 'multiple'; + readonly maxAssociations: number; + readonly maxRetries: number; + readonly retryDelayMs: number; + readonly timeoutMs: number; +}): Result { + return ok(new DimseSender({ + host: options.host, + port: options.port, + calledAETitle: options.calledAETitle, + callingAETitle: options.callingAETitle, + maxRetries: options.maxRetries, + retryDelayMs: options.retryDelayMs, + timeoutMs: options.timeoutMs, + })); +} diff --git a/packages/connectors/src/dicom/dicom-dispatcher.ts b/packages/connectors/src/dicom/dicom-dispatcher.ts index 0efc6f1..b9064ce 100644 --- a/packages/connectors/src/dicom/dicom-dispatcher.ts +++ b/packages/connectors/src/dicom/dicom-dispatcher.ts @@ -1,12 +1,13 @@ // =========================================== // DICOM Dispatcher (Destination Connector) // =========================================== -// Wraps @ubercode/dcmtk DicomSender to send DICOM files via C-STORE. +// A dcmjs-dimse DICOM SCU that sends DICOM files via C-STORE. // Expects message content to be a file path. import * as path from 'node:path'; import { tryCatch, type Result } from '@mirthless/core-util'; import type { DestinationConnectorRuntime, ConnectorMessage, ConnectorResponse } from '../base.js'; +import { createDimseSender } from './dcmjs-dimse-adapter.js'; // ----- Config ----- @@ -75,7 +76,7 @@ export class DicomDispatcher implements DestinationConnectorRuntime { constructor(config: DicomDispatcherConfig, createSender?: SenderFactory) { this.config = config; - this.createSender = createSender ?? defaultSenderFactory; + this.createSender = createSender ?? createDimseSender; } async onDeploy(): Promise> { @@ -178,34 +179,4 @@ export class DicomDispatcher implements DestinationConnectorRuntime { } } -// ----- Default factory (uses @ubercode/dcmtk) ----- -function defaultSenderFactory(options: { - readonly host: string; - readonly port: number; - readonly calledAETitle: string; - readonly callingAETitle: string; - readonly mode: 'single' | 'multiple'; - readonly maxAssociations: number; - readonly maxRetries: number; - readonly retryDelayMs: number; - readonly timeoutMs: number; -}): Result { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { DicomSender: DcmtkDicomSender } = require('@ubercode/dcmtk') as { - DicomSender: { - create(opts: Record): Result; - }; - }; - return DcmtkDicomSender.create({ - host: options.host, - port: options.port, - calledAETitle: options.calledAETitle, - callingAETitle: options.callingAETitle, - mode: options.mode, - maxAssociations: options.maxAssociations, - maxRetries: options.maxRetries, - retryDelayMs: options.retryDelayMs, - timeoutMs: options.timeoutMs, - }); -} diff --git a/packages/connectors/src/dicom/dicom-receiver.ts b/packages/connectors/src/dicom/dicom-receiver.ts index 7a874ac..0b975ad 100644 --- a/packages/connectors/src/dicom/dicom-receiver.ts +++ b/packages/connectors/src/dicom/dicom-receiver.ts @@ -1,8 +1,8 @@ // =========================================== // DICOM Receiver (Source Connector) // =========================================== -// Wraps @ubercode/dcmtk DicomReceiver to receive DICOM C-STORE -// associations and dispatch each file as a message into the pipeline. +// A dcmjs-dimse DICOM SCP that receives C-STORE associations and dispatches each +// received instance as a message into the pipeline. // Content = file path; metadata goes into sourceMap. import * as fs from 'node:fs/promises'; @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { tryCatch, type Result } from '@mirthless/core-util'; import type { SourceConnectorRuntime, MessageDispatcher, RawMessage } from '../base.js'; import { createConnectorLogger, errorInfo, type ConnectorLogger } from '../logger.js'; +import { createDimseReceiver } from './dcmjs-dimse-adapter.js'; // ----- Constants ----- @@ -99,7 +100,7 @@ export class DicomReceiver implements SourceConnectorRuntime { constructor(config: DicomReceiverConfig, createReceiver?: ReceiverFactory) { this.config = config; - this.createReceiver = createReceiver ?? defaultReceiverFactory; + this.createReceiver = createReceiver ?? createDimseReceiver; } setDispatcher(dispatcher: MessageDispatcher): void { @@ -275,29 +276,3 @@ export class DicomReceiver implements SourceConnectorRuntime { } } -// ----- Default factory (uses @ubercode/dcmtk) ----- - -function defaultReceiverFactory(options: { - readonly port: number; - readonly storageDir: string; - readonly aeTitle: string; - readonly minPoolSize: number; - readonly maxPoolSize: number; - readonly connectionTimeoutMs: number; -}): Result { - // Dynamic import-style require to avoid bundling issues - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { DicomReceiver: DcmtkDicomReceiver } = require('@ubercode/dcmtk') as { - DicomReceiver: { - create(opts: Record): Result; - }; - }; - return DcmtkDicomReceiver.create({ - port: options.port, - storageDir: options.storageDir, - aeTitle: options.aeTitle, - minPoolSize: options.minPoolSize, - maxPoolSize: options.maxPoolSize, - connectionTimeoutMs: options.connectionTimeoutMs, - }); -} diff --git a/packages/engine/examples/sandbox-script-example.ts b/packages/engine/examples/sandbox-script-example.ts new file mode 100644 index 0000000..d6cc605 --- /dev/null +++ b/packages/engine/examples/sandbox-script-example.ts @@ -0,0 +1,27 @@ +// Example channel transformer, authored against the sandbox ambient types +// (../sandbox-globals.d.ts). This file is type-checked by tsconfig.sandbox.json +// (`pnpm --filter @mirthless/engine typecheck:scripts`) but never bundled or run +// — it exists to prove the shipped ambient types resolve for real authoring. +// +// A real transformer body would end in `return `; a top-level return is +// illegal in a standalone .ts file, so this example demonstrates the same global +// surface via statements only. + +const parsed: Hl7MessageProxy = parseHL7(rawData); +logger.info(`received ${parsed.messageType} (${parsed.messageControlId})`); + +const mrn: string = parsed.get('PID.3') ?? 'unknown'; +channelMap['lastMrn'] = mrn; +$c['lastType'] = parsed.messageType; + +// Durable lookup via a Collection, typed end to end. +const priorVisits: Promise = getCollection('visits').find({ mrn }); +void priorVisits.then((records: CollectionRecord[]) => { + logger.debug(`prior visits: ${records.length}`); +}); + +// Config + global maps, and a plain string transform on msg. +const facility: unknown = configMap['facility.name']; +globalMap['seenCount'] = ((globalMap['seenCount'] as number | undefined) ?? 0) + 1; +const normalized: string = String(msg).trim().toUpperCase(); +void [facility, normalized]; diff --git a/packages/engine/package.json b/packages/engine/package.json index 9a8c4a5..ef0ee0c 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -14,7 +14,9 @@ "scripts": { "dev": "tsc --watch", "build": "tsc", + "typecheck:scripts": "tsc -p tsconfig.sandbox.json", "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "clean": "rimraf dist" diff --git a/packages/engine/sandbox-globals.d.ts b/packages/engine/sandbox-globals.d.ts new file mode 100644 index 0000000..9c4eaae --- /dev/null +++ b/packages/engine/sandbox-globals.d.ts @@ -0,0 +1,143 @@ +// =========================================== +// Sandbox Globals — Ambient Types for Channel Scripts +// =========================================== +// The canonical type surface a filter / transformer / connector script sees when +// it runs inside the engine sandbox (packages/engine/src/sandbox/). Author TS +// channel scripts against these globals to get real type-checking and editor +// completion. +// +// This file lives at the package root (NOT under src/) on purpose: including it +// in the engine's own tsconfig would leak `msg`, `logger`, etc. into the engine +// source as globals. It is consumed only by script-authoring tooling — a +// tsconfig that lists it, or the Monaco editor. The web admin ships an identical +// surface as a string (packages/web/src/lib/sandbox-types.ts) for Monaco; keep +// the two in sync when the sandbox API changes. + +/** HL7 message proxy returned by parseHL7(). Path-based access to HL7 fields. */ +interface Hl7MessageProxy { + /** Get field value by HL7 path (e.g., "MSH.9.1", "PID.3"). */ + get(path: string): string | undefined; + /** Set field value by HL7 path. */ + set(path: string, value: string): void; + /** Delete a field by HL7 path. */ + delete(path: string): void; + /** Serialize back to HL7 pipe-delimited format. */ + toString(): string; + /** Message type (e.g., "ADT^A01"). */ + readonly messageType: string; + /** Message control ID (MSH.10). */ + readonly messageControlId: string; + /** Count of repeating segments by name (e.g., "OBX"). */ + getSegmentCount(name: string): number; + /** Get raw segment string by name and optional repeat index. */ + getSegmentString(name: string, index?: number): string | undefined; +} + +/** Logger for sandbox scripts. */ +interface SandboxLogger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + debug(message: string): void; +} + +// ----- Global Variables ----- + +/** Current message being processed. Type depends on inbound data type. */ +declare var msg: unknown; +/** Temporary working map. Persists across pipeline stages within a single message. */ +declare var tmp: Record; +/** Original inbound raw data string. */ +declare var rawData: string; +/** Source system metadata. Propagates through the pipeline. */ +declare var sourceMap: Record; +/** Channel-scoped persistent state. Cleared on channel redeploy. */ +declare var channelMap: Record; +/** Connector-scoped state. Lifetime of the connector instance. */ +declare var connectorMap: Record; +/** Response data from destination sends. */ +declare var responseMap: Record; +/** Per-channel map that persists across messages within a deployment. */ +declare var globalChannelMap: Record; +/** Global map (persistent, shared across all channels). */ +declare var globalMap: Record; +/** Configuration map (read-only, frozen). Access via "category.key" format. */ +declare var configMap: Readonly>; +/** Logger for sandbox scripts. */ +declare var logger: SandboxLogger; + +// ----- Map Shortcut Aliases ----- + +/** Alias for channelMap. */ +declare var $c: Record; +/** Alias for responseMap. */ +declare var $r: Record; +/** Alias for globalChannelMap. */ +declare var $g: Record; +/** Alias for globalMap. */ +declare var $gc: Record; + +// ----- Global Functions ----- + +/** Parse an HL7v2 message string into a proxy object. */ +declare function parseHL7(raw: string): Hl7MessageProxy; + +/** Create an HL7 ACK message from the original raw message. */ +declare function createACK(originalRaw: string, ackCode: string, textMessage?: string): string; + +/** A record stored in / returned from a collection. */ +interface CollectionRecord { + readonly id: string; + readonly fields: Readonly>; + readonly payload: string | null; + readonly expireAt: string | null; + readonly createdAt: string; +} + +/** Handle returned by getCollection(name) for reading/writing records. */ +interface CollectionHandle { + store( + fields: Record, + payload: string, + options?: { expireAt?: string; ttlSeconds?: number }, + ): Promise; + find( + match: Record, + options?: { + filter?: Record>; + latest?: boolean; + limit?: number; + order?: 'asc' | 'desc'; + }, + ): Promise; +} + +/** Access a durable, keyed record store shared across channels. */ +declare function getCollection(name: string): CollectionHandle; + +/** Load a configured resource's content by name (or null if none). */ +declare function getResource(name: string): Promise; + +/** The result of an httpFetch call. */ +interface HttpFetchResult { + readonly status: number; + readonly statusText: string; + readonly headers: Readonly>; + readonly body: string; +} + +/** Perform an outbound HTTP request (private/loopback ranges blocked for SSRF). */ +declare function httpFetch( + url: string, + options?: { method?: string; headers?: Record; body?: string; timeout?: number }, +): Promise; + +/** Route a raw message into another deployed, started channel by name. */ +declare function routeMessage(channelName: string, rawData: string): Promise<{ success: boolean; response?: string }>; + +/** Run a parameterized query against a named Data Source. Use params ($1, $2, …). */ +declare function dbQuery( + dataSourceName: string, + sql: string, + params?: readonly unknown[], +): Promise[]>; diff --git a/packages/engine/src/__tests__/connector-dicom.e2e.test.ts b/packages/engine/src/__tests__/connector-dicom.e2e.test.ts new file mode 100644 index 0000000..6fd1f17 --- /dev/null +++ b/packages/engine/src/__tests__/connector-dicom.e2e.test.ts @@ -0,0 +1,102 @@ +// =========================================== +// DICOM connector cascade — dcmjs-dimse SCU → SCP (in-process, pure JS) +// =========================================== +// Channel A: TCP source (message = a .dcm file path) → DICOM destination +// (C-STORE SCU sends the file). +// Channel B: DICOM source (C-STORE SCP receives it, writes a .dcm) → sink. +// Proves the DICOM connectors move a real DICOM object over the wire between two +// channels. Pure JavaScript (dcmjs-dimse) — no native binaries, no external +// server — so it runs in the default lane like the other connectors. + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + TcpMllpReceiver, + DicomReceiver, + DicomDispatcher, + clearChannelRegistry, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; + +const SCP_PORT = 11114; +const TCP_PORT = 17762; +const SAMPLE_DCM = fileURLToPath(new URL('./fixtures/sample.dcm', import.meta.url)); + +let deployed: DeployedChannel[] = []; +const tempDirs: string[] = []; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + for (const d of tempDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }); + clearChannelRegistry(); +}); + +describe('DICOM connector cascade (dcmjs-dimse SCU → SCP)', () => { + it('Channel A stores a DICOM object to Channel B\'s SCP over C-STORE', async () => { + const storageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mless-dicom-')); + tempDirs.push(storageDir); + + const sink = new CaptureDestination(); + + // Channel B: DICOM SCP source → sink (content = received file path). + const channelB = await deployChannel({ + channelId: '00000000-0000-0000-0000-conndicom00b', + dataType: 'RAW', + source: new DicomReceiver({ + port: SCP_PORT, + storageDir, + aeTitle: 'TESTSCP', + minPoolSize: 1, + maxPoolSize: 2, + connectionTimeoutMs: 15_000, + dispatchMode: 'PER_FILE', + postAction: 'NONE', + moveToDirectory: '', + }), + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + + // Channel A: TCP source (message = the .dcm path) → DICOM SCU destination. + const channelA = await deployChannel({ + channelId: '00000000-0000-0000-0000-conndicom00a', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: TCP_PORT, maxConnections: 10 }), + destinations: [{ + metaDataId: 1, + name: 'DICOM Out', + connector: new DicomDispatcher({ + host: '127.0.0.1', + port: SCP_PORT, + calledAETitle: 'TESTSCP', + callingAETitle: 'TESTSCU', + mode: 'single', + maxAssociations: 1, + maxRetries: 0, + retryDelayMs: 0, + timeoutMs: 20_000, + allowedBaseDir: path.dirname(SAMPLE_DCM), + }), + }], + }); + deployed.push(channelA, channelB); + + // The message content IS the .dcm file path the SCU should send. + await sendMllp(TCP_PORT, SAMPLE_DCM, 20_000); + + for (let i = 0; i < 400 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 25)); + } + + expect(sink.received.length).toBeGreaterThanOrEqual(1); + const receivedPath = sink.lastContent() ?? ''; + const head = await fs.readFile(receivedPath); + // The received file is a real DICOM object: 128-byte preamble + "DICM". + expect(head.subarray(128, 132).toString('ascii')).toBe('DICM'); + expect(head.length).toBeGreaterThan(1000); + }, 45_000); +}); diff --git a/packages/engine/src/__tests__/connector-js.e2e.test.ts b/packages/engine/src/__tests__/connector-js.e2e.test.ts new file mode 100644 index 0000000..7f953a6 --- /dev/null +++ b/packages/engine/src/__tests__/connector-js.e2e.test.ts @@ -0,0 +1,105 @@ +// =========================================== +// JavaScript connector E2E — source + destination +// =========================================== +// The JS connectors run user scripts via a ScriptRunner the engine injects. +// These tests wire a real sandbox-backed runner (exactly as the production +// engine does) and drive messages through: +// - JS source: a polling script generates a message → delivered to a sink. +// - JS destination: a script transforms the message → its return value is the +// dispatch response. + +import { describe, it, expect, afterEach } from 'vitest'; +import { + JavaScriptReceiver, + JavaScriptDispatcher, + TcpMllpReceiver, + clearChannelRegistry, + type ConnectorMessage, +} from '@mirthless/connectors'; +import type { Result } from '@mirthless/core-util'; +import { VmSandboxExecutor, compileScript, DEFAULT_EXECUTION_OPTIONS } from '../index.js'; +import { createSandboxContext } from '../sandbox/sandbox-context.js'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; + +let deployed: DeployedChannel[] = []; +const sandbox = new VmSandboxExecutor(); + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +async function compile(source: string): Promise<{ code: string }> { + const r = await compileScript(source, { sourcefile: 'js-connector.js' }); + if (!r.ok) throw new Error(`compile failed: ${r.error.message}`); + return r.value; +} + +describe('JavaScript source connector (a polling script generates messages)', () => { + it('runs the source script and delivers its generated message to the destination', async () => { + const compiled = await compile("return 'HEARTBEAT|' + msg;"); + const source = new JavaScriptReceiver({ script: 'ignored', pollingIntervalMs: 100 }); + + // Inject the sandbox runner (as the engine does). Generate exactly one + // message so the polling source is deterministic. + let calls = 0; + source.setScriptRunner(async (): Promise> => { + calls += 1; + if (calls > 1) return { ok: true, value: null, error: null } as Result; + const exec = await sandbox.execute(compiled, createSandboxContext('OK', 'OK'), DEFAULT_EXECUTION_OPTIONS); + if (!exec.ok) return exec; + return { ok: true, value: exec.value.returnValue, error: null } as Result; + }); + + const sink = new CaptureDestination(); + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connjs00src1', + dataType: 'RAW', + source, + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + deployed.push(channel); + + for (let i = 0; i < 200 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(sink.received.length).toBeGreaterThanOrEqual(1); + expect(sink.lastContent()).toBe('HEARTBEAT|OK'); + }); +}); + +describe('JavaScript destination connector (a script transforms the message)', () => { + it('runs the destination script against the message and returns its result', async () => { + const compiled = await compile('return String(msg).toUpperCase();'); + const dispatcher = new JavaScriptDispatcher({ script: 'ignored' }); + + const seen: { input: string; output: unknown }[] = []; + dispatcher.setScriptRunner(async (_script: string, content: string, _cm: ConnectorMessage): Promise> => { + const exec = await sandbox.execute(compiled, createSandboxContext(content, content), DEFAULT_EXECUTION_OPTIONS); + const output = exec.ok ? exec.value.returnValue : null; + seen.push({ input: content, output }); + if (!exec.ok) return exec; + return { ok: true, value: output, error: null } as Result; + }); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connjs0dst01', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: 17721, maxConnections: 10 }), + destinations: [{ metaDataId: 1, name: 'JS Dest', connector: dispatcher }], + }); + deployed.push(channel); + + await sendMllp(17721, 'MSH|^~\\&|abc'); + for (let i = 0; i < 200 && seen.length === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + + expect(seen).toHaveLength(1); + expect(seen[0]?.input).toBe('MSH|^~\\&|abc'); + expect(seen[0]?.output).toBe('MSH|^~\\&|ABC'); + expect(channel.store.messageCount()).toBe(1); + }); +}); diff --git a/packages/engine/src/__tests__/connector-matrix.e2e.test.ts b/packages/engine/src/__tests__/connector-matrix.e2e.test.ts new file mode 100644 index 0000000..7f64e96 --- /dev/null +++ b/packages/engine/src/__tests__/connector-matrix.e2e.test.ts @@ -0,0 +1,256 @@ +// =========================================== +// Connector Matrix E2E: exercise each connector by USING it +// =========================================== +// One real message pushed through a real source connector, transformed, and +// delivered through a real destination connector — asserting the payload that +// actually landed on the other side (a file on disk, an HTTP body downstream). +// +// TCP/MLLP + Channel connectors are covered in real-messaging.e2e.test.ts. +// This file adds File and HTTP. Each new connector added here is a small, +// self-contained block following the same shape. + +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as http from 'node:http'; +import { + FileReceiver, + FileDispatcher, + FILE_SORT_BY, + FILE_POST_ACTION, + HttpReceiver, + HttpDispatcher, + FhirDispatcher, + TcpMllpReceiver, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; + +let deployed: DeployedChannel[] = []; +const tempDirs: string[] = []; +const servers: http.Server[] = []; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + for (const s of servers.splice(0)) await new Promise((r) => s.close(() => { r(); })); + for (const d of tempDirs.splice(0)) await fs.rm(d, { recursive: true, force: true }); +}); + +// ----- File connector ----- + +describe('File connector (drop a file in → transform → write a file out)', () => { + it('picks up a dropped file, transforms it, and writes the result to the destination dir', async () => { + const srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mless-file-src-')); + const destDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mless-file-dst-')); + tempDirs.push(srcDir, destDir); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connfile0001', + dataType: 'RAW', + source: new FileReceiver({ + directory: srcDir, + fileFilter: '*.hl7', + pollingIntervalMs: 100, + sortBy: FILE_SORT_BY.NAME, + charset: 'utf-8', + binary: false, + checkFileAge: false, + fileAgeMs: 0, + postAction: FILE_POST_ACTION.DELETE, + moveToDirectory: '', + }), + transformer: "return String(msg) + '::processed';", + destinations: [{ + metaDataId: 1, + name: 'File Out', + connector: new FileDispatcher({ + directory: destDir, + outputPattern: '${messageId}.out', + charset: 'utf-8', + binary: false, + tempFileEnabled: false, + appendMode: false, + }), + }], + }); + deployed.push(channel); + + await fs.writeFile(path.join(srcDir, 'input.hl7'), 'MSH|^~\\&|FILE|IN'); + + const outPath = path.join(destDir, '1.out'); + await waitForAsync(async () => { + try { await fs.access(outPath); return true; } catch { return false; } + }); + + const written = await fs.readFile(outPath, 'utf-8'); + expect(written).toBe('MSH|^~\\&|FILE|IN::processed'); + expect(channel.store.messageCount()).toBe(1); + + // Source consumed the file (postAction DELETE). + const remaining = await fs.readdir(srcDir); + expect(remaining).not.toContain('input.hl7'); + }, 15_000); +}); + +// ----- HTTP connector ----- + +describe('HTTP connector (POST in → transform → POST out to a downstream server)', () => { + it('receives an HTTP POST, transforms the body, and delivers it to a downstream HTTP server', async () => { + const received: string[] = []; + const downstream = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += String(chunk); }); + req.on('end', () => { received.push(body); res.writeHead(200); res.end('OK'); }); + }); + await listen(downstream, DOWNSTREAM_PORT); + servers.push(downstream); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connhttp0001', + dataType: 'RAW', + source: new HttpReceiver({ + host: '127.0.0.1', + port: HTTP_SRC_PORT, + path: '/in', + method: 'POST', + responseContentType: 'text/plain', + responseStatusCode: 200, + errorStatusCode: 500, + maxBodyBytes: 1_000_000, + }), + transformer: 'return String(msg).toUpperCase();', + destinations: [{ + metaDataId: 1, + name: 'HTTP Out', + connector: new HttpDispatcher({ + url: `http://127.0.0.1:${String(DOWNSTREAM_PORT)}/out`, + method: 'POST', + headers: {}, + contentType: 'text/plain', + responseTimeout: 5_000, + }), + }], + }); + deployed.push(channel); + + const response = await httpPost(HTTP_SRC_PORT, '/in', 'hello world'); + expect(response.status).toBe(200); + + await waitForSync(() => received.length === 1); + expect(received[0]).toBe('HELLO WORLD'); + expect(channel.store.messageCount()).toBe(1); + }, 15_000); +}); + +// ----- FHIR connector (destination) ----- + +describe('FHIR connector (POST a resource in → transform to FHIR → POST to a FHIR server)', () => { + it('transforms the message into a FHIR Patient and POSTs it to a FHIR endpoint', async () => { + const received: { path: string; contentType: string; body: string }[] = []; + const fhirServer = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += String(chunk); }); + req.on('end', () => { + received.push({ path: req.url ?? '', contentType: req.headers['content-type'] ?? '', body }); + res.writeHead(201, { 'Content-Type': 'application/fhir+json', Location: '/Patient/123' }); + res.end(body); + }); + }); + await listen(fhirServer, FHIR_PORT); + servers.push(fhirServer); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-connfhir0001', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: FHIR_SRC_PORT, maxConnections: 10 }), + // Build a FHIR Patient from the HL7 PID-5 name. + transformer: [ + "const pid = String(msg).split(String.fromCharCode(13)).find((l) => l.indexOf('PID') === 0) || '';", + "const name = (pid.split('|')[5] || '').split('^');", + 'const patient = { resourceType: "Patient", name: [{ family: name[0] || "", given: [name[1] || ""] }] };', + 'return JSON.stringify(patient);', + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'FHIR Out', + connector: new FhirDispatcher({ + baseUrl: `http://127.0.0.1:${String(FHIR_PORT)}`, + resourceType: 'Patient', + method: 'POST', + authType: 'NONE', + authConfig: {}, + format: 'json', + timeout: 5_000, + headers: {}, + }), + }], + }); + deployed.push(channel); + + await sendMllp(FHIR_SRC_PORT, [ + 'MSH|^~\\&|S|F|R|F|20260101||ADT^A01|1|P|2.5', + 'PID|||1^^^MRN||DOE^JOHN', + ].join('\r')); + + await waitForSync(() => received.length === 1); + const posted = received[0]; + expect(posted?.path).toBe('/Patient'); + expect(posted?.contentType).toContain('application/fhir+json'); + const resource = JSON.parse(posted?.body ?? '{}') as { resourceType: string; name: { family: string; given: string[] }[] }; + expect(resource.resourceType).toBe('Patient'); + expect(resource.name[0]?.family).toBe('DOE'); + expect(resource.name[0]?.given[0]).toBe('JOHN'); + }); +}); + +// ----- ports + helpers ----- + +const HTTP_SRC_PORT = 17701; +const DOWNSTREAM_PORT = 17702; +const FHIR_PORT = 17703; +const FHIR_SRC_PORT = 17704; + +async function listen(server: http.Server, port: number): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { resolve(); }); + }); +} + +interface HttpResult { readonly status: number; readonly body: string; } + +async function httpPost(port: number, urlPath: string, body: string): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: urlPath, method: 'POST', headers: { 'Content-Type': 'text/plain', 'Content-Length': Buffer.byteLength(body) } }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += String(chunk); }); + res.on('end', () => { resolve({ status: res.statusCode ?? 0, body: data }); }); + }, + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} + +async function waitForSync(predicate: () => boolean, timeoutMs = 10_000): Promise { + const start = Date.now(); + for (;;) { + if (predicate()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitForSync: condition not met in time'); + await new Promise((r) => setTimeout(r, 10)); + } +} + +async function waitForAsync(predicate: () => Promise, timeoutMs = 10_000): Promise { + const start = Date.now(); + for (;;) { + if (await predicate()) return; + if (Date.now() - start > timeoutMs) throw new Error('waitForAsync: condition not met in time'); + await new Promise((r) => setTimeout(r, 20)); + } +} diff --git a/packages/engine/src/__tests__/fixtures/sample.dcm b/packages/engine/src/__tests__/fixtures/sample.dcm new file mode 100644 index 0000000..479bfb3 Binary files /dev/null and b/packages/engine/src/__tests__/fixtures/sample.dcm differ diff --git a/packages/engine/src/__tests__/integration/connector-db.itest.ts b/packages/engine/src/__tests__/integration/connector-db.itest.ts new file mode 100644 index 0000000..9806184 --- /dev/null +++ b/packages/engine/src/__tests__/integration/connector-db.itest.ts @@ -0,0 +1,71 @@ +// =========================================== +// Database destination connector — real Postgres +// =========================================== +// Sends a message through a channel whose destination is the Database dispatcher, +// then reads the row back from a real table to prove it landed. +// Runs only when DATABASE_URL points at a *_test database. + +import { it, expect, beforeAll, afterAll } from 'vitest'; +import { ConnectionPool, TcpMllpReceiver, DatabaseDispatcher, clearChannelRegistry } from '@mirthless/connectors'; +import { deployChannel, teardownAll } from '../support/e2e-harness.js'; +import { sendMllp } from '../support/tcp-helpers.js'; +import { describeDb, requireDb } from './gates.js'; + +const TABLE = 'e2e_db_dest'; +const PORT = 17731; + +describeDb('Database destination connector (real Postgres)', () => { + const pool = new ConnectionPool(); + + beforeAll(async () => { + const cfg = requireDb(); + const created = await pool.create({ ...cfg, maxConnections: 2 }); + if (!created.ok) throw created.error; + await pool.query(`DROP TABLE IF EXISTS ${TABLE}`, []); + await pool.query(`CREATE TABLE ${TABLE} (id serial PRIMARY KEY, message_id integer, payload text)`, []); + }); + + afterAll(async () => { + await pool.query(`DROP TABLE IF EXISTS ${TABLE}`, []); + await pool.destroy(); + clearChannelRegistry(); + }); + + it('inserts the transformed message into a real table via the Database dispatcher', async () => { + const cfg = requireDb(); + const dispatcher = new DatabaseDispatcher({ + host: cfg.host, + port: cfg.port, + database: cfg.database, + username: cfg.user, + password: cfg.password, + query: `INSERT INTO ${TABLE} (message_id, payload) VALUES ($\{messageId}, $\{content})`, + useTransaction: false, + returnGeneratedKeys: false, + }); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-conndb000001', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: PORT, maxConnections: 10 }), + transformer: "return String(msg) + '::db';", + destinations: [{ metaDataId: 1, name: 'DB Out', connector: dispatcher }], + }); + + try { + await sendMllp(PORT, 'MSH|^~\\&|DBTEST'); + + let rows: readonly Record[] = []; + for (let i = 0; i < 200; i++) { + const r = await pool.query(`SELECT message_id, payload FROM ${TABLE}`, []); + if (r.ok && r.value.rows.length > 0) { rows = r.value.rows; break; } + await new Promise((res) => setTimeout(res, 10)); + } + + expect(rows).toHaveLength(1); + expect(String(rows[0]?.payload)).toBe('MSH|^~\\&|DBTEST::db'); + } finally { + await teardownAll([channel]); + } + }); +}); diff --git a/packages/engine/src/__tests__/integration/connector-mail.itest.ts b/packages/engine/src/__tests__/integration/connector-mail.itest.ts new file mode 100644 index 0000000..1296cbf --- /dev/null +++ b/packages/engine/src/__tests__/integration/connector-mail.itest.ts @@ -0,0 +1,95 @@ +// =========================================== +// SMTP + IMAP connector cascade — real GreenMail server +// =========================================== +// Channel A: TCP source → SMTP destination (sends an email). +// Channel B: Email (IMAP) source polls the inbox → transform → sink. +// A message pushed into A is emailed, picked up over IMAP by B, and delivered. +// Runs only when SMTP_TEST_HOST is set (docker compose --profile test up mail-test). + +import { it, expect, afterEach } from 'vitest'; +import { + TcpMllpReceiver, + SmtpDispatcher, + EmailReceiver, + clearChannelRegistry, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from '../support/e2e-harness.js'; +import { sendMllp } from '../support/tcp-helpers.js'; +import { describeMail, requireMail } from './gates.js'; + +const TCP_PORT = 17751; + +let deployed: DeployedChannel[] = []; +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +describeMail('SMTP + IMAP connector cascade (real GreenMail)', () => { + it('Channel A emails the message via SMTP; Channel B\'s IMAP source picks it up', async () => { + const cfg = requireMail(); + const address = cfg.address; // envelope email, e.g. mirth@example.com + + const sink = new CaptureDestination(); + + // Channel B: IMAP source polls INBOX for our subject. + const channelB = await deployChannel({ + channelId: '00000000-0000-0000-0000-connmail000b', + dataType: 'RAW', + source: new EmailReceiver({ + host: cfg.host, + port: cfg.imapPort, + secure: false, + username: cfg.username, + password: cfg.password, + protocol: 'IMAP', + folder: 'INBOX', + pollingIntervalMs: 1000, + postAction: 'MARK_READ', + moveToFolder: '', + subjectFilter: 'E2E-MAIL', + includeAttachments: false, + }), + transformer: "return String(msg).trim() + '::mail';", + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + + // Channel A: TCP source → SMTP destination emails ${msg}. + const channelA = await deployChannel({ + channelId: '00000000-0000-0000-0000-connmail000a', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: TCP_PORT, maxConnections: 10 }), + destinations: [{ + metaDataId: 1, + name: 'SMTP Out', + connector: new SmtpDispatcher({ + host: cfg.host, + port: cfg.smtpPort, + secure: false, + requireTLS: false, + from: address, + to: address, + cc: '', + bcc: '', + subject: 'E2E-MAIL', + bodyTemplate: '${msg}', + contentType: 'text/plain', + attachContent: false, + }), + }], + }); + deployed.push(channelA, channelB); + + await sendMllp(TCP_PORT, 'MAILTEST-PAYLOAD'); + + // Wait for SMTP delivery + the 1s IMAP poll to pick it up. + for (let i = 0; i < 300 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 50)); + } + + expect(sink.received.length).toBeGreaterThanOrEqual(1); + expect(sink.lastContent()).toContain('MAILTEST-PAYLOAD'); + expect(sink.lastContent()).toContain('::mail'); + }); +}); diff --git a/packages/engine/src/__tests__/integration/connector-sftp.itest.ts b/packages/engine/src/__tests__/integration/connector-sftp.itest.ts new file mode 100644 index 0000000..d218616 --- /dev/null +++ b/packages/engine/src/__tests__/integration/connector-sftp.itest.ts @@ -0,0 +1,89 @@ +// =========================================== +// SFTP connector cascade — real SFTP server +// =========================================== +// The vision's "Channel SFTP listen + another channel with an SFTP destination": +// Channel A: TCP source → SFTP destination (uploads a file to the server). +// Channel B: SFTP source (polls the same dir) → transform → sink. +// A message pushed into A is uploaded over SFTP, picked up by B, and delivered. +// Runs only when SFTP_TEST_HOST is set (docker compose --profile test up sftp-test). + +import { it, expect, afterEach } from 'vitest'; +import { + TcpMllpReceiver, + SftpReceiver, + SftpDispatcher, + SFTP_POST_ACTION, + clearChannelRegistry, +} from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from '../support/e2e-harness.js'; +import { sendMllp } from '../support/tcp-helpers.js'; +import { describeSftp, requireSftp } from './gates.js'; + +const TCP_PORT = 17741; + +let deployed: DeployedChannel[] = []; +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +describeSftp('SFTP connector cascade (real SFTP server)', () => { + it('Channel A uploads via SFTP destination; Channel B\'s SFTP source picks it up', async () => { + const cfg = requireSftp(); + const conn = { + host: cfg.host, + port: cfg.port, + username: cfg.username, + password: cfg.password, + strictHostKey: false, + }; + + const sink = new CaptureDestination(); + + // Channel B: SFTP source polls the upload dir, deletes after processing. + const channelB = await deployChannel({ + channelId: '00000000-0000-0000-0000-connsftp000b', + dataType: 'RAW', + source: new SftpReceiver({ + ...conn, + remoteDirectory: cfg.baseDir, + filePattern: '*.hl7', + pollingIntervalMs: 200, + afterProcessing: SFTP_POST_ACTION.DELETE, + moveToDirectory: '', + minFileAgeMs: 0, + }), + transformer: "return String(msg) + '::sftp';", + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + + // Channel A: TCP source → SFTP destination uploads ${messageId}.hl7. + const channelA = await deployChannel({ + channelId: '00000000-0000-0000-0000-connsftp000a', + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: TCP_PORT, maxConnections: 10 }), + destinations: [{ + metaDataId: 1, + name: 'SFTP Out', + connector: new SftpDispatcher({ + ...conn, + remoteDirectory: cfg.baseDir, + fileNameTemplate: 'msg-${messageId}.hl7', + appendMode: false, + }), + }], + }); + deployed.push(channelA, channelB); + + await sendMllp(TCP_PORT, 'MSH|^~\\&|SFTPTEST'); + + // Wait for the upload → poll → download → deliver round trip. + for (let i = 0; i < 300 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + } + + expect(sink.received).toHaveLength(1); + expect(sink.lastContent()).toBe('MSH|^~\\&|SFTPTEST::sftp'); + }); +}); diff --git a/packages/engine/src/__tests__/integration/gates.ts b/packages/engine/src/__tests__/integration/gates.ts new file mode 100644 index 0000000..71731e0 --- /dev/null +++ b/packages/engine/src/__tests__/integration/gates.ts @@ -0,0 +1,113 @@ +// =========================================== +// Integration-test gates +// =========================================== +// Each connector-integration suite runs only when its backing service is +// configured (via env), and self-skips otherwise. This keeps the integration +// lane runnable anywhere while still exercising real protocols in CI / docker. + +import { describe } from 'vitest'; +import type { PoolConfig } from '@mirthless/connectors'; + +// ----- Postgres (DATABASE_URL ending in *_test) ----- + +export interface TestDbConfig extends PoolConfig { + readonly url: string; +} + +function readTestDbConfig(): TestDbConfig | null { + const url = process.env.DATABASE_URL; + if (!url) return null; + try { + const u = new URL(url); + const database = u.pathname.replace(/^\//, ''); + // Guard developer/prod DBs: only a *_test database is safe for CREATE/DROP. + if (!/_test$/i.test(database)) return null; + return { + url, + host: u.hostname, + port: u.port ? Number(u.port) : 5432, + database, + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + maxConnections: 4, + idleTimeoutMs: 10_000, + connectionTimeoutMs: 10_000, + }; + } catch { + return null; + } +} + +export const dbConfig = readTestDbConfig(); +export const describeDb = dbConfig ? describe : describe.skip; + +/** Non-null DB config for use inside hooks/tests (never called when skipped). */ +export function requireDb(): TestDbConfig { + if (!dbConfig) throw new Error('DATABASE_URL is not a *_test database'); + return dbConfig; +} + +// ----- SFTP (SFTP_TEST_HOST) ----- + +export interface TestSftpConfig { + readonly host: string; + readonly port: number; + readonly username: string; + readonly password: string; + readonly baseDir: string; +} + +function readSftpConfig(): TestSftpConfig | null { + const host = process.env.SFTP_TEST_HOST; + if (!host) return null; + return { + host, + port: process.env.SFTP_TEST_PORT ? Number(process.env.SFTP_TEST_PORT) : 2222, + username: process.env.SFTP_TEST_USER ?? 'mirth', + password: process.env.SFTP_TEST_PASSWORD ?? 'mirthpw', + baseDir: process.env.SFTP_TEST_DIR ?? '/upload', + }; +} + +export const sftpConfig = readSftpConfig(); +export const describeSftp = sftpConfig ? describe : describe.skip; +export function requireSftp(): TestSftpConfig { + if (!sftpConfig) throw new Error('SFTP_TEST_HOST is not set'); + return sftpConfig; +} + +// ----- SMTP / IMAP (GreenMail: SMTP_TEST_HOST) ----- + +export interface TestMailConfig { + readonly host: string; + readonly smtpPort: number; + readonly imapPort: number; + /** IMAP/SMTP login (GreenMail user login — e.g. "mirth"). */ + readonly username: string; + readonly password: string; + /** Mailbox email address (e.g. "mirth@example.com") — the SMTP envelope. */ + readonly address: string; +} + +function readMailConfig(): TestMailConfig | null { + const host = process.env.SMTP_TEST_HOST; + if (!host) return null; + return { + host, + smtpPort: process.env.SMTP_TEST_PORT ? Number(process.env.SMTP_TEST_PORT) : 3025, + imapPort: process.env.IMAP_TEST_PORT ? Number(process.env.IMAP_TEST_PORT) : 3143, + username: process.env.MAIL_TEST_USER ?? 'mirth', + password: process.env.MAIL_TEST_PASSWORD ?? 'mirthpw', + address: process.env.MAIL_TEST_ADDRESS ?? 'mirth@example.com', + }; +} + +export const mailConfig = readMailConfig(); +export const describeMail = mailConfig ? describe : describe.skip; +export function requireMail(): TestMailConfig { + if (!mailConfig) throw new Error('SMTP_TEST_HOST is not set'); + return mailConfig; +} + +// (DICOM needs no gate: the dcmjs-dimse connector runs in-process, so its cascade +// test lives in the default lane — packages/engine/src/__tests__/connector-dicom.e2e.test.ts.) diff --git a/packages/engine/src/__tests__/real-messaging.e2e.test.ts b/packages/engine/src/__tests__/real-messaging.e2e.test.ts new file mode 100644 index 0000000..8426298 --- /dev/null +++ b/packages/engine/src/__tests__/real-messaging.e2e.test.ts @@ -0,0 +1,202 @@ +// =========================================== +// Real-Messaging E2E: send messages THROUGH channels +// =========================================== +// These tests do what unit tests can't: push a real message in through a real +// source connector, let it run the full 8-stage pipeline (with real esbuild- +// compiled TS scripts + injected code templates), and assert on the transformed +// output that a real destination connector received — plus the persisted rows. +// +// Test 1 TCP/MLLP in → TS source transformer (calls a FUNCTION code template) +// → TCP/MLLP out. Asserts the wire output + RAW/SENT content rows. +// +// Test 2 Cascade A → B → C over the in-memory Channel connector, converting +// the payload at each hop (HL7 → JSON → XML), proving multi-channel +// routing + per-channel transformation. This is the "silly but good" +// cascade from the project vision. + +import { describe, it, expect, afterEach } from 'vitest'; +import { CONTENT_TYPE } from '@mirthless/core-models'; +import { + TcpMllpReceiver, + TcpMllpDispatcher, + ChannelReceiver, + ChannelDispatcher, + clearChannelRegistry, +} from '@mirthless/connectors'; +import type { CodeTemplateData } from '../index.js'; +import { + deployChannel, + teardownAll, + CaptureDestination, + type DeployedChannel, +} from './support/e2e-harness.js'; +import { startMllpCaptureServer, sendMllp, type MllpCaptureServer } from './support/tcp-helpers.js'; + +// ----- Fixtures ----- + +const HL7_ADT = [ + 'MSH|^~\\&|SENDER|FACILITY|RECEIVER|FACILITY|20260228120000||ADT^A01|12345|P|2.5', + 'EVN|A01|20260228120000', + 'PID|||12345^^^MRN||DOE^JOHN||19800101|M', + 'PV1||I|ICU^101^A', +].join('\r'); + +// A FUNCTION code template, authored in TypeScript, injected into the source +// transformer's scope. Exercises the template-injection + TS-compile path. +const REDACT_TEMPLATE: CodeTemplateData = { + type: 'FUNCTION', + contexts: ['SOURCE_FILTER_TRANSFORMER'], + code: [ + '/** Redact the MRN in a raw HL7 v2 message. */', + 'function redactMrn(hl7: string): string {', + " return hl7.split('12345^^^MRN').join('REDACTED^^^MRN');", + '}', + ].join('\n'), +}; + +const SRC_PORT_1 = 17681; +const DEST_PORT_1 = 17682; +const SRC_PORT_2 = 17683; + +let deployed: DeployedChannel[] = []; +let captureServer: MllpCaptureServer | null = null; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + if (captureServer) { captureServer.close(); captureServer = null; } + clearChannelRegistry(); +}); + +// ----- Test 1: real TCP in → TS+template transform → real TCP out ----- + +describe('real message through a single channel (TCP → transform → TCP)', () => { + it('applies a TS transformer using a code template and delivers to a real TCP destination', async () => { + captureServer = await startMllpCaptureServer(DEST_PORT_1); + + const channel = await deployChannel({ + channelId: '00000000-0000-0000-0000-realmsg00001', + dataType: 'HL7V2', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: SRC_PORT_1, maxConnections: 10 }), + templates: [REDACT_TEMPLATE], + // TS source transformer: uses the injected template, then appends a segment. + transformer: [ + 'const out: string = redactMrn(String(msg));', + "return out + '\\rZZZ|processed';", + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'TCP Out', + connector: new TcpMllpDispatcher({ + host: '127.0.0.1', port: DEST_PORT_1, maxConnections: 5, responseTimeout: 5_000, + }), + }], + }); + deployed.push(channel); + + const ack = await sendMllp(SRC_PORT_1, HL7_ADT); + expect(ack).toContain('MSH'); + + // The destination server received the TRANSFORMED message off the wire. + // Allow the async dispatch to settle. + await viWaitFor(() => captureServer!.received.length === 1); + const delivered = captureServer.received[0] ?? ''; + expect(delivered).toContain('REDACTED^^^MRN'); + expect(delivered).toContain('ZZZ|processed'); + expect(delivered).not.toContain('12345^^^MRN'); + + // Persisted rows: RAW = original inbound, SENT = transformed outbound. + expect(channel.store.messageCount()).toBe(1); + const raw = channel.store.contentOf(1, 0, CONTENT_TYPE.RAW); + expect(raw).toContain('12345^^^MRN'); + const sent = channel.store.contentOf(1, 1, CONTENT_TYPE.SENT); + expect(sent).toContain('ZZZ|processed'); + expect(sent).toContain('REDACTED^^^MRN'); + }); +}); + +// ----- Test 2: A → B → C cascade with per-hop conversion ----- + +describe('cascade across channels (A → B → C), converting the payload at each hop', () => { + it('routes HL7 into A, converts to JSON in B, to XML in C, delivering the final XML', async () => { + const CH_A = '00000000-0000-0000-0000-cascade0000a'; + const CH_B = '00000000-0000-0000-0000-cascade0000b'; + const CH_C = '00000000-0000-0000-0000-cascade0000c'; + + const sink = new CaptureDestination(); + + // Deploy C first so its ChannelReceiver is registered before B routes to it, + // and B before A. (Registration happens on start, which deployChannel does.) + const channelC = await deployChannel({ + channelId: CH_C, + // RAW: msg stays the raw string so string-based XML editing is deterministic. + dataType: 'RAW', + source: new ChannelReceiver({ channelId: CH_C }), + // C: tag the XML as having passed through C. + transformer: "return String(msg).replace('', 'C');", + destinations: [{ metaDataId: 1, name: 'Sink', connector: sink }], + }); + + const channelB = await deployChannel({ + channelId: CH_B, + // RAW: receive A's JSON string verbatim so JSON.parse sees a string. + dataType: 'RAW', + source: new ChannelReceiver({ channelId: CH_B }), + // B: JSON → XML. + transformer: [ + 'const o = JSON.parse(String(msg));', + "return '' + o.name + '' + o.source + '';", + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'To C', + dataType: 'XML', + connector: new ChannelDispatcher({ targetChannelId: CH_C, waitForResponse: true }), + }], + }); + + const channelA = await deployChannel({ + channelId: CH_A, + dataType: 'HL7V2', + source: new TcpMllpReceiver({ host: '127.0.0.1', port: SRC_PORT_2, maxConnections: 10 }), + // A: HL7 → JSON (pull PID-5 patient name). + transformer: [ + 'const lines = String(msg).split(String.fromCharCode(13));', + "const pid = lines.find((l) => l.indexOf('PID') === 0) || '';", + "const name = pid.split('|')[5] || '';", + "return JSON.stringify({ name: name, source: 'A' });", + ].join('\n'), + destinations: [{ + metaDataId: 1, + name: 'To B', + dataType: 'JSON', + connector: new ChannelDispatcher({ targetChannelId: CH_B, waitForResponse: true }), + }], + }); + deployed.push(channelA, channelB, channelC); + + await sendMllp(SRC_PORT_2, HL7_ADT); + await viWaitFor(() => sink.received.length === 1); + + const finalXml = sink.lastContent() ?? ''; + expect(finalXml).toContain('DOE^JOHN'); + expect(finalXml).toContain('A'); + expect(finalXml).toContain('C'); + + // Each channel processed exactly one message. + expect(channelA.store.messageCount()).toBe(1); + expect(channelB.store.messageCount()).toBe(1); + expect(channelC.store.messageCount()).toBe(1); + }); +}); + +// ----- small polling helper (avoids a fixed sleep for async dispatch) ----- + +async function viWaitFor(predicate: () => boolean, timeoutMs = 5_000): Promise { + const start = Date.now(); + for (;;) { + if (predicate()) return; + if (Date.now() - start > timeoutMs) throw new Error('viWaitFor: condition not met in time'); + await new Promise((r) => setTimeout(r, 10)); + } +} diff --git a/packages/engine/src/__tests__/sandbox-types-consistency.test.ts b/packages/engine/src/__tests__/sandbox-types-consistency.test.ts new file mode 100644 index 0000000..9a03526 --- /dev/null +++ b/packages/engine/src/__tests__/sandbox-types-consistency.test.ts @@ -0,0 +1,38 @@ +// =========================================== +// Sandbox ambient types ↔ Monaco defs drift guard +// =========================================== +// The engine ships the canonical ambient sandbox types (sandbox-globals.d.ts) +// for authoring TS channel scripts; the web admin carries a mirror string for +// Monaco (packages/web/src/lib/sandbox-types.ts) because web can't import engine. +// This test fails if the two declare a different set of global names, so the +// copies can't silently drift. + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const dtsPath = fileURLToPath(new URL('../../sandbox-globals.d.ts', import.meta.url)); +const webPath = fileURLToPath(new URL('../../../web/src/lib/sandbox-types.ts', import.meta.url)); + +/** Extract the set of `declare var|function` global names from a source string. */ +function declaredGlobals(source: string): Set { + const names = new Set(); + const re = /declare\s+(?:var|function)\s+(\$?\w+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + if (m[1]) names.add(m[1]); + } + return names; +} + +describe('sandbox ambient types stay in sync with the Monaco defs', () => { + it('engine .d.ts and web SANDBOX_TYPE_DEFS declare the same global names', () => { + const engine = declaredGlobals(readFileSync(dtsPath, 'utf-8')); + const web = declaredGlobals(readFileSync(webPath, 'utf-8')); + + expect(engine.size).toBeGreaterThan(10); + const onlyInEngine = [...engine].filter((n) => !web.has(n)).sort(); + const onlyInWeb = [...web].filter((n) => !engine.has(n)).sort(); + expect({ onlyInEngine, onlyInWeb }).toEqual({ onlyInEngine: [], onlyInWeb: [] }); + }); +}); diff --git a/packages/engine/src/__tests__/support/e2e-harness.ts b/packages/engine/src/__tests__/support/e2e-harness.ts new file mode 100644 index 0000000..17c3f18 --- /dev/null +++ b/packages/engine/src/__tests__/support/e2e-harness.ts @@ -0,0 +1,375 @@ +// =========================================== +// E2E Channel Harness +// =========================================== +// Reusable support for tests that ACTUALLY send messages through channels. +// Assembles a real channel runtime (sandbox + 8-stage pipeline + real source +// and destination connectors) backed by an in-memory message store, so a test +// can: build a channel from a declarative spec, push a message in through the +// source connector, and assert on the transformed output + persisted rows. +// +// Scripts (source filter/transformer, per-destination filter/transformer/ +// response-transformer) are written as TS/JS source and compiled through the +// real esbuild-backed compiler with code-template injection — exercising the +// same path production uses. No mocking of anything we own. + +import { + VmSandboxExecutor, + MessageProcessor, + ChannelRuntime, + DEFAULT_EXECUTION_OPTIONS, + compileScript, + prependTemplates, +} from '../../index.js'; +import type { + MessageStore, + PipelineConfig, + DestinationConfig, + DestinationScripts, + ChannelScripts, + ChannelRuntimeConfig, + SendToDestination, + DestinationResponse, + CompiledScript, + CodeTemplateData, +} from '../../index.js'; +import type { + SourceConnectorRuntime, + DestinationConnectorRuntime, + ConnectorMessage, + ConnectorResponse, +} from '@mirthless/connectors'; +import type { Result } from '@mirthless/core-util'; + +// ----- Result helper ----- + +function ok(value: T): Result { + return { ok: true, value, error: null } as Result; +} + +// ----- In-memory message store ----- + +export interface StoredContentRow { + readonly channelId: string; + readonly messageId: number; + readonly metaDataId: number; + readonly contentType: number; + readonly content: string; + readonly dataType: string; +} + +export interface StoredConnectorMessageRow { + readonly channelId: string; + readonly messageId: number; + readonly metaDataId: number; + readonly connectorName: string; + status: string; +} + +export interface StoredStatRow { + readonly channelId: string; + readonly metaDataId: number; + received: number; + filtered: number; + sent: number; + errored: number; +} + +export interface InMemoryStore extends MessageStore { + readonly contents: readonly StoredContentRow[]; + readonly connectorMessages: readonly StoredConnectorMessageRow[]; + readonly stats: readonly StoredStatRow[]; + /** Number of source messages created. */ + messageCount(): number; + /** Content of a specific stored row, or null. */ + contentOf(messageId: number, metaDataId: number, contentType: number): string | null; +} + +/** Build a fresh in-memory store fully satisfying the MessageStore contract. */ +export function createInMemoryStore(): InMemoryStore { + let nextMessageId = 1; + const messages: { channelId: string; messageId: number; processed: boolean }[] = []; + const connectorMessages: StoredConnectorMessageRow[] = []; + const contents: StoredContentRow[] = []; + const stats: StoredStatRow[] = []; + + return { + contents, + connectorMessages, + stats, + messageCount: () => messages.length, + contentOf: (messageId, metaDataId, contentType) => + contents.find( + (c) => c.messageId === messageId && c.metaDataId === metaDataId && c.contentType === contentType, + )?.content ?? null, + + createMessage: async (channelId, _serverId, correlationId) => { + const messageId = nextMessageId++; + messages.push({ channelId, messageId, processed: false }); + return ok({ messageId, correlationId: correlationId ?? `corr-${channelId}-${String(messageId)}` }); + }, + createConnectorMessage: async (channelId, messageId, metaDataId, name, status) => { + connectorMessages.push({ channelId, messageId, metaDataId, connectorName: name, status }); + return ok(undefined); + }, + updateConnectorMessageStatus: async (channelId, messageId, metaDataId, status) => { + const cm = connectorMessages.find( + (m) => m.channelId === channelId && m.messageId === messageId && m.metaDataId === metaDataId, + ); + if (cm) cm.status = status; + return ok(undefined); + }, + storeContent: async (channelId, messageId, metaDataId, contentType, content, dataType) => { + contents.push({ channelId, messageId, metaDataId, contentType, content, dataType }); + return ok(undefined); + }, + markProcessed: async (channelId, messageId) => { + const msg = messages.find((m) => m.channelId === channelId && m.messageId === messageId); + if (msg) msg.processed = true; + return ok(undefined); + }, + enqueue: async () => ok(undefined), + loadContent: async (channelId, messageId, metaDataId, contentType) => { + const entry = contents.find( + (c) => c.channelId === channelId && c.messageId === messageId + && c.metaDataId === metaDataId && c.contentType === contentType, + ); + return ok(entry?.content ?? null); + }, + dequeue: async () => ok([]), + release: async () => ok(undefined), + incrementStats: async (channelId, metaDataId, _serverId, field) => { + let stat = stats.find((s) => s.channelId === channelId && s.metaDataId === metaDataId); + if (!stat) { + stat = { channelId, metaDataId, received: 0, filtered: 0, sent: 0, errored: 0 }; + stats.push(stat); + } + stat[field] += 1; + return ok(undefined); + }, + }; +} + +// ----- Channel spec ----- + +export interface DestinationSpec { + readonly metaDataId: number; + readonly name: string; + readonly connector: DestinationConnectorRuntime; + /** dataType tagged on the outbound ConnectorMessage; defaults to channel dataType. */ + readonly dataType?: string; + readonly filter?: string; + readonly transformer?: string; + readonly responseTransformer?: string; +} + +export interface ChannelSpec { + readonly channelId: string; + readonly serverId?: string; + readonly dataType: string; + readonly source: SourceConnectorRuntime; + readonly templates?: ReadonlyArray; + /** Channel preprocessor script. */ + readonly preprocessor?: string; + /** Source filter script (`return true` to pass). */ + readonly filter?: string; + /** Source transformer script. */ + readonly transformer?: string; + readonly destinations: ReadonlyArray; +} + +export interface DeployedChannel { + readonly runtime: ChannelRuntime; + readonly store: InMemoryStore; + teardown(): Promise; +} + +async function compile( + code: string, + templates: ReadonlyArray, + context: string, + sourcefile: string, +): Promise { + const withTemplates = prependTemplates(code, templates, context); + const result = await compileScript(withTemplates, { sourcefile }); + if (!result.ok) { + throw new Error(`compile failed for ${sourcefile}: ${result.error.message}`); + } + return result.value; +} + +async function buildDestinationConfigs( + spec: ChannelSpec, + templates: ReadonlyArray, +): Promise { + const configs: DestinationConfig[] = []; + for (const d of spec.destinations) { + const scripts: { + filter?: CompiledScript; + transformer?: CompiledScript; + responseTransformer?: CompiledScript; + } = {}; + const base = `${spec.channelId}/dest-${String(d.metaDataId)}`; + if (d.filter !== undefined) { + scripts.filter = await compile(d.filter, templates, 'destinationFilter', `${base}-filter.ts`); + } + if (d.transformer !== undefined) { + scripts.transformer = await compile(d.transformer, templates, 'destinationTransformer', `${base}-transformer.ts`); + } + if (d.responseTransformer !== undefined) { + // Note: CONTEXT_MAP has no response-transformer key, so template injection + // is a no-op here (templates are unavailable in response transformers). + scripts.responseTransformer = await compile( + d.responseTransformer, templates, 'destinationResponseTransformer', `${base}-response.ts`, + ); + } + configs.push({ + metaDataId: d.metaDataId, + name: d.name, + enabled: true, + scripts: scripts as DestinationScripts, + queueMode: 'NEVER', + }); + } + return configs; +} + +/** + * Assemble, deploy, and start a channel from a declarative spec. + * The returned channel is live: pushing a message through `spec.source` + * runs the full pipeline and dispatches to the real destination connectors. + */ +export async function deployChannel(spec: ChannelSpec): Promise { + const sandbox = new VmSandboxExecutor(); + const store = createInMemoryStore(); + const serverId = spec.serverId ?? 'e2e-server'; + const templates = spec.templates ?? []; + + const sourceScripts: { + preprocessor?: CompiledScript; + sourceFilter?: CompiledScript; + sourceTransformer?: CompiledScript; + } = {}; + if (spec.preprocessor !== undefined) { + sourceScripts.preprocessor = await compile( + spec.preprocessor, templates, 'preprocessor', `${spec.channelId}/preprocessor.ts`, + ); + } + if (spec.filter !== undefined) { + sourceScripts.sourceFilter = await compile( + spec.filter, templates, 'sourceFilter', `${spec.channelId}/source-filter.ts`, + ); + } + if (spec.transformer !== undefined) { + sourceScripts.sourceTransformer = await compile( + spec.transformer, templates, 'sourceTransformer', `${spec.channelId}/source-transformer.ts`, + ); + } + + const destConfigs = await buildDestinationConfigs(spec, templates); + + const pipelineConfig: PipelineConfig = { + channelId: spec.channelId, + serverId, + dataType: spec.dataType, + scripts: sourceScripts as ChannelScripts, + destinations: destConfigs, + }; + + const destByMeta = new Map(spec.destinations.map((d) => [d.metaDataId, d])); + const sendFn: SendToDestination = async (metaDataId, messageId, content, signal) => { + const d = destByMeta.get(metaDataId); + if (!d) { + return ok({ status: 'ERROR', content: '', errorMessage: `unknown destination ${String(metaDataId)}` }); + } + const message: ConnectorMessage = { + channelId: spec.channelId, + messageId, + metaDataId, + content, + dataType: d.dataType ?? spec.dataType, + }; + return d.connector.send(message, signal); + }; + + const processor = new MessageProcessor(sandbox, store, sendFn, pipelineConfig, DEFAULT_EXECUTION_OPTIONS); + + const runtime = new ChannelRuntime(); + const destinations = new Map( + spec.destinations.map((d) => [d.metaDataId, d.connector]), + ); + const runtimeConfig: ChannelRuntimeConfig = { + channelId: spec.channelId, + source: spec.source, + destinations, + onMessage: async (raw) => { + const result = await processor.processMessage( + { rawContent: raw.content, sourceMap: raw.sourceMap }, + AbortSignal.timeout(30_000), + ); + if (!result.ok) return result; + // Map the pipeline's SENT success status to the source-dispatch PROCESSED + // status the runtime/source connectors expect (same as production engine). + const { messageId, status, response } = result.value; + const dispatchStatus = status === 'FILTERED' ? 'FILTERED' : status === 'ERROR' ? 'ERROR' : 'PROCESSED'; + return ok(response !== undefined + ? { messageId, status: dispatchStatus, response } + : { messageId, status: dispatchStatus }); + }, + }; + + const deployResult = await runtime.deploy(runtimeConfig); + if (!deployResult.ok) throw new Error(`deploy failed: ${deployResult.error.message}`); + const startResult = await runtime.start(); + if (!startResult.ok) throw new Error(`start failed: ${startResult.error.message}`); + + return { + runtime, + store, + teardown: async () => { + const state = runtime.getState(); + if (state === 'STARTED' || state === 'PAUSED') await runtime.stop(); + if (runtime.getState() === 'STOPPED') await runtime.undeploy(); + }, + }; +} + +/** Stop and undeploy several channels, ignoring already-stopped ones. */ +export async function teardownAll(channels: readonly DeployedChannel[]): Promise { + for (const c of channels) { + await c.teardown(); + } +} + +// ----- Capture destination (an in-memory sink connector) ----- + +/** + * A real DestinationConnectorRuntime that records every message it is sent. + * Use as the terminal sink of a chain to assert on the final transformed output + * without needing an external server. It IS a real connector (implements the + * full lifecycle + send contract) — the message genuinely flows through the + * pipeline and dispatch path to reach it. + */ +export class CaptureDestination implements DestinationConnectorRuntime { + readonly received: ConnectorMessage[] = []; + private readonly reply: (msg: ConnectorMessage) => string; + + constructor(reply?: (msg: ConnectorMessage) => string) { + this.reply = reply ?? (() => 'ACK'); + } + + /** Content of the most recently received message, or null. */ + lastContent(): string | null { + return this.received.at(-1)?.content ?? null; + } + + async onDeploy(): Promise> { return ok(undefined); } + async onStart(): Promise> { return ok(undefined); } + async onStop(): Promise> { return ok(undefined); } + async onHalt(): Promise> { return ok(undefined); } + async onUndeploy(): Promise> { return ok(undefined); } + + async send(message: ConnectorMessage, _signal: AbortSignal): Promise> { + this.received.push(message); + return ok({ status: 'SENT', content: this.reply(message) }); + } +} diff --git a/packages/engine/src/__tests__/support/tcp-helpers.ts b/packages/engine/src/__tests__/support/tcp-helpers.ts new file mode 100644 index 0000000..0c28582 --- /dev/null +++ b/packages/engine/src/__tests__/support/tcp-helpers.ts @@ -0,0 +1,68 @@ +// =========================================== +// TCP/MLLP Test Helpers +// =========================================== +// Real TCP servers/clients for exercising the TCP/MLLP connectors end to end. + +import * as net from 'node:net'; +import { wrapMllp, MllpParser } from '@mirthless/connectors'; + +export interface MllpCaptureServer { + /** Every framed message the server has received, in order. */ + readonly received: readonly string[]; + readonly port: number; + close(): void; +} + +/** + * Start a TCP server that speaks MLLP: it records each framed message it + * receives and replies with an ACK (customisable via `ackFor`). + */ +export async function startMllpCaptureServer( + port: number, + ackFor?: (message: string) => string, +): Promise { + const received: string[] = []; + const server = net.createServer((socket) => { + const parser = new MllpParser(); + socket.on('data', (chunk: Buffer) => { + for (const message of parser.parse(chunk)) { + received.push(message); + socket.write(wrapMllp(ackFor ? ackFor(message) : 'MSH|^~\\&|ACK|||AA')); + } + }); + socket.on('error', () => { /* client reset — ignore in tests */ }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { resolve(); }); + }); + + return { received, port, close: () => { server.close(); } }; +} + +/** Connect, send one MLLP-framed message, and resolve with the framed reply. */ +export async function sendMllp(port: number, message: string, timeoutMs = 10_000): Promise { + return new Promise((resolve, reject) => { + const client = net.createConnection({ host: '127.0.0.1', port }, () => { + client.write(wrapMllp(message)); + }); + + const parser = new MllpParser(); + client.on('data', (chunk: Buffer) => { + const messages = parser.parse(chunk); + const first = messages[0]; + if (first !== undefined) { + client.end(); + resolve(first); + } + }); + + client.on('error', reject); + const timer = setTimeout(() => { + client.destroy(); + reject(new Error('sendMllp: timed out waiting for reply')); + }, timeoutMs); + timer.unref(); + }); +} diff --git a/packages/engine/src/__tests__/ts-channel-scripts.e2e.test.ts b/packages/engine/src/__tests__/ts-channel-scripts.e2e.test.ts new file mode 100644 index 0000000..9264255 --- /dev/null +++ b/packages/engine/src/__tests__/ts-channel-scripts.e2e.test.ts @@ -0,0 +1,80 @@ +// =========================================== +// TypeScript channel scripts — compiled + run end to end +// =========================================== +// Proves that channel scripts authored in real TypeScript (interfaces, generics, +// typed helpers, `as const`) are transpiled by the production esbuild path and +// run correctly through a live channel. The ambient types an author writes these +// against ship in packages/engine/sandbox-globals.d.ts. + +import { describe, it, expect, afterEach } from 'vitest'; +import { TcpMllpReceiver, clearChannelRegistry } from '@mirthless/connectors'; +import { deployChannel, teardownAll, CaptureDestination, type DeployedChannel } from './support/e2e-harness.js'; +import { sendMllp } from './support/tcp-helpers.js'; + +const HL7 = [ + 'MSH|^~\\&|S|F|R|F|20260101||ADT^A01|1|P|2.5', + 'PID|||99887^^^MRN||SMITH^JANE||19700101|F', +].join('\r'); + +let deployed: DeployedChannel[] = []; + +afterEach(async () => { + await teardownAll(deployed); + deployed = []; + clearChannelRegistry(); +}); + +async function runScriptChannel(channelId: string, port: number, transformer: string): Promise { + const sink = new CaptureDestination(); + const channel = await deployChannel({ + channelId, + dataType: 'RAW', + source: new TcpMllpReceiver({ host: '127.0.0.1', port, maxConnections: 10 }), + transformer, + destinations: [{ metaDataId: 1, name: 'sink', connector: sink }], + }); + deployed.push(channel); + await sendMllp(port, HL7); + // Poll for the async dispatch to land. + for (let i = 0; i < 200 && sink.received.length === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + return sink; +} + +describe('TypeScript channel scripts run through a real channel', () => { + it('transformer with an interface + typed helper (HL7 → typed JSON)', async () => { + const transformer = [ + 'interface Patient { readonly mrn: string; readonly name: string; }', + 'function parsePatient(hl7: string): Patient {', + " const pid = hl7.split(String.fromCharCode(13)).find((l: string) => l.startsWith('PID')) ?? '';", + " const fields: readonly string[] = pid.split('|');", + " const mrn: string = (fields[3] ?? '').split('^')[0] ?? '';", + " return { mrn, name: fields[5] ?? '' };", + '}', + 'const p: Patient = parsePatient(String(msg));', + 'return JSON.stringify(p);', + ].join('\n'); + + const sink = await runScriptChannel('00000000-0000-0000-0000-tsscript0001', 17711, transformer); + expect(sink.received).toHaveLength(1); + const out = JSON.parse(sink.lastContent() ?? '{}') as { mrn: string; name: string }; + expect(out.mrn).toBe('99887'); + expect(out.name).toBe('SMITH^JANE'); + }); + + it('transformer with a generic arrow + as-const union', async () => { + const transformer = [ + 'const at = (arr: readonly T[], i: number): T | undefined => arr[i];', + "const KIND = { ADT: 'ADT', ORU: 'ORU' } as const;", + 'type Kind = typeof KIND[keyof typeof KIND];', + "const seg: string = at(String(msg).split(String.fromCharCode(13)), 0) ?? '';", + "const kind: Kind = seg.includes('ADT') ? KIND.ADT : KIND.ORU;", + "return kind + '|' + String(at(seg.split('|'), 9) ?? '');", + ].join('\n'); + + const sink = await runScriptChannel('00000000-0000-0000-0000-tsscript0002', 17712, transformer); + expect(sink.received).toHaveLength(1); + expect(sink.lastContent()).toContain('ADT|'); + }); +}); diff --git a/packages/engine/tsconfig.sandbox.json b/packages/engine/tsconfig.sandbox.json new file mode 100644 index 0000000..5a67871 --- /dev/null +++ b/packages/engine/tsconfig.sandbox.json @@ -0,0 +1,12 @@ +{ + "//": "Type-checks example channel scripts against the shipped sandbox ambient types (sandbox-globals.d.ts). Not part of the build. Run: pnpm --filter @mirthless/engine typecheck:scripts", + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "composite": false + }, + "include": ["sandbox-globals.d.ts", "examples/**/*.ts"] +} diff --git a/packages/engine/vitest.integration.config.ts b/packages/engine/vitest.integration.config.ts new file mode 100644 index 0000000..65898e2 --- /dev/null +++ b/packages/engine/vitest.integration.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +// =========================================== +// Integration (real services) Vitest Config +// =========================================== +// Runs ONLY the *.itest.ts suites, which drive real messages through connectors +// backed by real infrastructure (Postgres, SFTP, SMTP/IMAP) provided by the +// docker-compose test services. Each suite self-skips when its service env is +// absent, so this config is safe to run anywhere. Kept separate from the default +// vitest.config.ts (which excludes *.itest.ts) so `pnpm test` never needs infra. + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.itest.ts'], + passWithNoTests: true, + pool: 'forks', + testTimeout: 20_000, + hookTimeout: 30_000, + }, +}); diff --git a/packages/server/src/services/__tests__/channel-clone.service.test.ts b/packages/server/src/services/__tests__/channel-clone.service.test.ts index 2e55711..b5040c8 100644 --- a/packages/server/src/services/__tests__/channel-clone.service.test.ts +++ b/packages/server/src/services/__tests__/channel-clone.service.test.ts @@ -219,15 +219,18 @@ function setupCreateMocks(channel: Record): void { const tx = { insert: vi.fn().mockImplementation(() => { insertCallCount++; - if (insertCallCount === 1) { - return { - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([channel]), + // Channel insert (call 1) returns the created row; later inserts return + // []. Every .values() supports both `.returning()` and direct await. + const rows = insertCallCount === 1 ? [channel] : []; + return { + values: vi.fn().mockReturnValue( + Object.assign(Promise.resolve(rows), { + returning: vi.fn().mockResolvedValue(rows), }), - }; - } - return { values: vi.fn().mockResolvedValue(undefined) }; + ), + }; }), + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }; return fn(tx); }); diff --git a/packages/server/src/services/__tests__/channel.service.test.ts b/packages/server/src/services/__tests__/channel.service.test.ts index 9b7b2b9..6b3affc 100644 --- a/packages/server/src/services/__tests__/channel.service.test.ts +++ b/packages/server/src/services/__tests__/channel.service.test.ts @@ -791,15 +791,19 @@ describe('ChannelService', () => { const tx = { insert: vi.fn().mockImplementation(() => { insertCallCount++; - if (insertCallCount === 1) { - return { - values: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([channel]), + // Channel insert (call 1) returns the created row; later inserts + // (scripts, destinations, filters, transformers) return []. Every + // .values() supports both `.returning()` and direct await. + const rows = insertCallCount === 1 ? [channel] : []; + return { + values: vi.fn().mockReturnValue( + Object.assign(Promise.resolve(rows), { + returning: vi.fn().mockResolvedValue(rows), }), - }; - } - return { values: vi.fn().mockResolvedValue(undefined) }; + ), + }; }), + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), }; return fn(tx); }); diff --git a/packages/server/src/services/channel.service.ts b/packages/server/src/services/channel.service.ts index ff03126..95829a8 100644 --- a/packages/server/src/services/channel.service.ts +++ b/packages/server/src/services/channel.service.ts @@ -447,6 +447,104 @@ function buildCloneInput(source: ChannelDetail, newName: string): CreateChannelI }; } +// ----- Filter / transformer persistence (shared by create + update) ----- + +/** Drizzle transaction handle, as passed to `db.transaction(async (tx) => …)`. */ +type ChannelTx = Parameters[0]>[0]; + +/** + * Replace a channel's filters (and their rules) with the provided set. + * Delete-and-reinsert; a source filter has `metaDataId: null`, a destination + * filter is mapped to its connector row via `destIdByMetaDataId`. + */ +async function syncFilters( + tx: ChannelTx, + channelId: string, + filters: NonNullable, + destIdByMetaDataId: ReadonlyMap, +): Promise { + await tx.delete(channelFilters).where(eq(channelFilters.channelId, channelId)); + + for (const filter of filters) { + let resolvedConnectorId: string | null = filter.connectorId ?? null; + if (resolvedConnectorId === null && filter.metaDataId !== undefined && filter.metaDataId !== null) { + resolvedConnectorId = destIdByMetaDataId.get(filter.metaDataId) ?? null; + } + + const [inserted] = await tx + .insert(channelFilters) + .values({ channelId, connectorId: resolvedConnectorId }) + .returning({ id: channelFilters.id }); + + if (inserted && filter.rules.length > 0) { + const ruleValues = filter.rules.map((rule, idx) => ({ + filterId: inserted.id, + sequenceNumber: idx, + enabled: rule.enabled, + name: rule.name ?? null, + operator: rule.operator, + type: rule.type, + script: rule.script ?? null, + field: rule.field ?? null, + condition: rule.condition ?? null, + values: rule.values ?? null, + })); + await tx.insert(filterRules).values(ruleValues); + } + } +} + +/** + * Replace a channel's transformers (and their steps) with the provided set. + * Delete-and-reinsert; a source transformer has `metaDataId: null`, a + * destination transformer is mapped to its connector row via `destIdByMetaDataId`. + */ +async function syncTransformers( + tx: ChannelTx, + channelId: string, + transformers: NonNullable, + destIdByMetaDataId: ReadonlyMap, +): Promise { + await tx.delete(channelTransformers).where(eq(channelTransformers.channelId, channelId)); + + for (const transformer of transformers) { + let resolvedConnectorId: string | null = transformer.connectorId ?? null; + if (resolvedConnectorId === null && transformer.metaDataId !== undefined && transformer.metaDataId !== null) { + resolvedConnectorId = destIdByMetaDataId.get(transformer.metaDataId) ?? null; + } + + const [inserted] = await tx + .insert(channelTransformers) + .values({ + channelId, + connectorId: resolvedConnectorId, + inboundDataType: transformer.inboundDataType, + outboundDataType: transformer.outboundDataType, + inboundProperties: transformer.inboundProperties, + outboundProperties: transformer.outboundProperties, + inboundTemplate: transformer.inboundTemplate ?? null, + outboundTemplate: transformer.outboundTemplate ?? null, + }) + .returning({ id: channelTransformers.id }); + + if (inserted && transformer.steps.length > 0) { + const stepValues = transformer.steps.map((step, idx) => ({ + transformerId: inserted.id, + sequenceNumber: idx, + enabled: step.enabled, + name: step.name ?? null, + type: step.type, + script: step.script ?? null, + sourceField: step.sourceField ?? null, + targetField: step.targetField ?? null, + defaultValue: step.defaultValue ?? null, + mapping: step.mapping ?? null, + })); + await tx.insert(transformerSteps).values(stepValues); + } + } +} + // ----- Service ----- export class ChannelService { @@ -565,7 +663,9 @@ export class ChannelService { await tx.insert(channelScripts).values(scriptValues); - // Insert destinations if provided + // Insert destinations if provided, tracking new IDs by metaDataId so + // source/destination filters + transformers can be mapped to them. + const destIdByMetaDataId = new Map(); if (input.destinations && input.destinations.length > 0) { const destValues = input.destinations.map((dest, index) => ({ channelId, @@ -582,7 +682,13 @@ export class ChannelService { waitForPrevious: dest.waitForPrevious, responseTransformer: dest.responseTransformer ?? null, })); - await tx.insert(channelConnectors).values(destValues); + const insertedDests = await tx.insert(channelConnectors).values(destValues).returning({ + id: channelConnectors.id, + metaDataId: channelConnectors.metaDataId, + }); + for (const d of insertedDests) { + destIdByMetaDataId.set(d.metaDataId, d.id); + } } // Insert metadata columns if provided @@ -596,6 +702,14 @@ export class ChannelService { await tx.insert(channelMetadataColumns).values(metaValues); } + // Persist filters + transformers (previously dropped on create — data loss). + if (input.filters && input.filters.length > 0) { + await syncFilters(tx, channelId, input.filters, destIdByMetaDataId); + } + if (input.transformers && input.transformers.length > 0) { + await syncTransformers(tx, channelId, input.transformers, destIdByMetaDataId); + } + return inserted; }); @@ -769,80 +883,9 @@ export class ChannelService { } } - // Sync filters if provided (delete-and-reinsert, cascade deletes rules) - if (input.filters) { - await tx.delete(channelFilters).where(eq(channelFilters.channelId, id)); - - for (const filter of input.filters) { - let resolvedConnectorId: string | null = filter.connectorId ?? null; - if (resolvedConnectorId === null && filter.metaDataId !== undefined && filter.metaDataId !== null) { - resolvedConnectorId = destIdByMetaDataId.get(filter.metaDataId) ?? null; - } - - const [inserted] = await tx - .insert(channelFilters) - .values({ channelId: id, connectorId: resolvedConnectorId }) - .returning({ id: channelFilters.id }); - - if (inserted && filter.rules.length > 0) { - const ruleValues = filter.rules.map((rule, idx) => ({ - filterId: inserted.id, - sequenceNumber: idx, - enabled: rule.enabled, - name: rule.name ?? null, - operator: rule.operator, - type: rule.type, - script: rule.script ?? null, - field: rule.field ?? null, - condition: rule.condition ?? null, - values: rule.values ?? null, - })); - await tx.insert(filterRules).values(ruleValues); - } - } - } - - // Sync transformers if provided (delete-and-reinsert, cascade deletes steps) - if (input.transformers) { - await tx.delete(channelTransformers).where(eq(channelTransformers.channelId, id)); - - for (const transformer of input.transformers) { - let resolvedConnectorId: string | null = transformer.connectorId ?? null; - if (resolvedConnectorId === null && transformer.metaDataId !== undefined && transformer.metaDataId !== null) { - resolvedConnectorId = destIdByMetaDataId.get(transformer.metaDataId) ?? null; - } - - const [inserted] = await tx - .insert(channelTransformers) - .values({ - channelId: id, - connectorId: resolvedConnectorId, - inboundDataType: transformer.inboundDataType, - outboundDataType: transformer.outboundDataType, - inboundProperties: transformer.inboundProperties, - outboundProperties: transformer.outboundProperties, - inboundTemplate: transformer.inboundTemplate ?? null, - outboundTemplate: transformer.outboundTemplate ?? null, - }) - .returning({ id: channelTransformers.id }); - - if (inserted && transformer.steps.length > 0) { - const stepValues = transformer.steps.map((step, idx) => ({ - transformerId: inserted.id, - sequenceNumber: idx, - enabled: step.enabled, - name: step.name ?? null, - type: step.type, - script: step.script ?? null, - sourceField: step.sourceField ?? null, - targetField: step.targetField ?? null, - defaultValue: step.defaultValue ?? null, - mapping: step.mapping ?? null, - })); - await tx.insert(transformerSteps).values(stepValues); - } - } - } + // Sync filters + transformers if provided (delete-and-reinsert). + if (input.filters) await syncFilters(tx, id, input.filters, destIdByMetaDataId); + if (input.transformers) await syncTransformers(tx, id, input.transformers, destIdByMetaDataId); }); const channel = await findChannel(id); diff --git a/packages/server/test/integration/channel-message-roundtrip.itest.ts b/packages/server/test/integration/channel-message-roundtrip.itest.ts index 84e0b49..895d830 100644 --- a/packages/server/test/integration/channel-message-roundtrip.itest.ts +++ b/packages/server/test/integration/channel-message-roundtrip.itest.ts @@ -9,6 +9,7 @@ import { beforeAll, afterAll, expect, it } from 'vitest'; import { randomUUID } from 'node:crypto'; import type { CreateChannelInput } from '@mirthless/core-models'; +import { createChannelSchema } from '@mirthless/core-models'; import { describeIntegration, loadServerModules, unwrap, type ServerModules } from './_setup.js'; describeIntegration('Channel CRUD + message round trip (real Postgres)', () => { @@ -72,4 +73,35 @@ describeIntegration('Channel CRUD + message round trip (real Postgres)', () => { const dup = await ChannelService.create({ ...input }); expect(dup.ok).toBe(false); }); + + it('persists source filter + transformer on CREATE (regression: they were silently dropped)', async () => { + const { ChannelService } = mods; + const input = createChannelSchema.parse({ + name: `itest-ft-${randomUUID()}`, + description: 'filter+transformer create', + enabled: false, + inboundDataType: 'RAW', + outboundDataType: 'RAW', + sourceConnectorType: 'JAVASCRIPT', + sourceConnectorProperties: {}, + responseMode: 'AUTO_AFTER_DESTINATIONS', + filters: [{ rules: [{ type: 'JAVASCRIPT', script: 'return true;' }] }], + transformers: [{ steps: [{ type: 'JAVASCRIPT', script: "msg = String(msg) + '::x';" }] }], + }); + + const created = unwrap(await ChannelService.create(input)); + const detail = unwrap(await ChannelService.getById(created.id)); + + expect(detail.transformers).toHaveLength(1); + const transformer = detail.transformers[0]; + expect(transformer?.steps).toHaveLength(1); + expect(transformer?.steps[0]?.script).toContain('::x'); + + expect(detail.filters).toHaveLength(1); + const filter = detail.filters[0]; + expect(filter?.rules).toHaveLength(1); + expect(filter?.rules[0]?.type).toBe('JAVASCRIPT'); + + unwrap(await ChannelService.delete(created.id)); + }); }); diff --git a/packages/web/src/lib/sandbox-types.ts b/packages/web/src/lib/sandbox-types.ts index 0bf092c..7831150 100644 --- a/packages/web/src/lib/sandbox-types.ts +++ b/packages/web/src/lib/sandbox-types.ts @@ -2,7 +2,12 @@ // Sandbox TypeScript Definitions // =========================================== // Type definitions for Monaco IntelliSense in sandbox script editors. -// Matches the sandbox executor globals from packages/engine/src/sandbox/. +// +// CANONICAL SOURCE: packages/engine/sandbox-globals.d.ts. This string mirrors +// that .d.ts for the Monaco editor (web cannot import the engine package). The +// two are kept in sync by a drift-guard test (sandbox-types-consistency.test.ts +// in the engine package) that asserts they declare the same global surface — +// update BOTH when the sandbox API changes. export const SANDBOX_TYPE_DEFS = ` /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 302611d..28fcfa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,9 +82,9 @@ importers: '@mirthless/engine': specifier: workspace:* version: link:../engine - '@ubercode/dcmtk': - specifier: ^0.3.0 - version: 0.3.0 + dcmjs-dimse: + specifier: ^0.3.2 + version: 0.3.3 generic-pool: specifier: ^3.9.0 version: 3.9.0 @@ -508,6 +508,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime-corejs3@7.29.7': + resolution: {integrity: sha512-ppj9ouYku+RX0ljtgZd+KMO5mkM2bCqg8H2PYAFWnLsHEIKIdRojqbJ2i3eVHrisuxy7nOFCmngTDdWtUCdXUQ==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} @@ -2044,10 +2048,6 @@ packages: resolution: {integrity: sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ubercode/dcmtk@0.3.0': - resolution: {integrity: sha512-H+ZduKCt1AViTOZzxdpkeTjpSeWmUEfWKm+ty6AcZNcyuPmAU0y7S/etgMpecdf7ExLTUspOlDFg0O4zuJxFgg==} - engines: {node: '>=20'} - '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -2112,6 +2112,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.18: + resolution: {integrity: sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==} + engines: {node: '>=12.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2185,10 +2189,16 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + async-event-emitter2@0.0.2: + resolution: {integrity: sha512-STaByrQCwDlQEb1stntYzVYzaS3zA02+3OMm6Ah6hjUWhmmthS0aJaD5t9GGQ/Q8H9vHl8O11PUm9s+z3bNUdQ==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2384,6 +2394,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-js-pure@3.49.0: + resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -2433,6 +2446,15 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dcmjs-codecs@0.0.8: + resolution: {integrity: sha512-qYilc+kqqpLYha6wytkIjzqsiByg7ol3KbONcJMcN7yG6G3FJOJo2s0PqtaCMM4sw9Ilcr9IyPgkKWW+tfuKQA==} + + dcmjs-dimse@0.3.3: + resolution: {integrity: sha512-u2MpnnydfQ3lZa25Y5UMqUBLRPoKM53SPJdQwvWe6TYJC/3fxgWfV55vJIoS6rN+hKPxVj87TOyM6QdUCClyzw==} + + dcmjs@0.50.3: + resolution: {integrity: sha512-h1bqE8K+JSDHEJTnVw0I/lqVO26zZ4uEwQ/lQ/mrntnCVYCbY+6XnGTgFYQw39PJ4DEDVTBzXfO9kNizYxsnqA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2957,6 +2979,9 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3093,6 +3118,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iota-array@1.0.0: + resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -3120,6 +3148,9 @@ packages: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -3333,6 +3364,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -3357,6 +3391,13 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + loglevel-plugin-prefix@0.8.4: + resolution: {integrity: sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==} + + loglevel@1.9.2: + resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} + engines: {node: '>= 0.6.0'} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -3405,6 +3446,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} @@ -3485,6 +3530,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + ndarray@1.0.19: + resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} + negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -3572,6 +3620,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4108,10 +4159,6 @@ packages: resolution: {integrity: sha512-degbccEEXfFHNlg1idQUT/Gy8t6ameS5rzE9etFY95iZHaLK4APZvYqpyX8PtVrmT3CzD+ut8GXKUbRkhBfFWg==} engines: {node: '>=18'} - stderr-lib@2.2.0: - resolution: {integrity: sha512-PI6uehBcOIsDshlCnTpaVog8NylyGe/Jerb7cVYisBLZJ8G2br+B0CO0G0g/DOgTouOXcwsZjzSToXLOBGnE1w==} - engines: {node: '>=18'} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4256,16 +4303,15 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} peerDependencies: typescript: '>=4.2.0' + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4576,9 +4622,6 @@ packages: zod@4.3.4: resolution: {integrity: sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==} - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zustand@5.0.11: resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==} engines: {node: '>=12.20.0'} @@ -4701,6 +4744,10 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime-corejs3@7.29.7': + dependencies: + core-js-pure: 3.49.0 + '@babel/runtime@7.28.6': {} '@babel/template@7.28.6': @@ -5834,13 +5881,6 @@ snapshots: '@typescript-eslint/types': 8.18.2 eslint-visitor-keys: 4.2.1 - '@ubercode/dcmtk@0.3.0': - dependencies: - fast-xml-parser: 5.10.0 - stderr-lib: 2.2.0 - tree-kill: 1.2.2 - zod: 4.3.6 - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.10.2)(tsx@4.19.2)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 @@ -5932,6 +5972,8 @@ snapshots: acorn@8.16.0: {} + adm-zip@0.5.18: {} + agent-base@7.1.4: {} ajv@6.14.0: @@ -6013,8 +6055,14 @@ snapshots: ast-types-flow@0.0.8: {} + async-event-emitter2@0.0.2: + dependencies: + async: 3.2.6 + async-function@1.0.0: {} + async@3.2.6: {} + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -6211,6 +6259,8 @@ snapshots: cookie@0.7.2: {} + core-js-pure@3.49.0: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -6274,6 +6324,33 @@ snapshots: dateformat@4.6.3: {} + dcmjs-codecs@0.0.8: + dependencies: + dcmjs: 0.50.3 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + + dcmjs-dimse@0.3.3: + dependencies: + async-event-emitter2: 0.0.2 + dcmjs: 0.50.3 + dcmjs-codecs: 0.0.8 + loglevel: 1.9.2 + loglevel-plugin-prefix: 0.8.4 + memorystream: 0.3.1 + smart-buffer: 4.2.0 + ts-mixer: 6.0.4 + + dcmjs@0.50.3: + dependencies: + '@babel/runtime-corejs3': 7.29.7 + adm-zip: 0.5.18 + gl-matrix: 3.4.4 + lodash.clonedeep: 4.5.0 + loglevel: 1.9.2 + ndarray: 1.0.19 + pako: 2.2.0 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -6972,6 +7049,8 @@ snapshots: github-from-package@0.0.0: {} + gl-matrix@3.4.4: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7118,6 +7197,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + iota-array@1.0.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -7147,6 +7228,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-buffer@1.1.6: {} + is-callable@1.2.7: {} is-core-module@2.16.1: @@ -7388,6 +7471,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.clonedeep@4.5.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -7404,6 +7489,10 @@ snapshots: lodash.once@4.1.1: {} + loglevel-plugin-prefix@0.8.4: {} + + loglevel@1.9.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -7442,6 +7531,8 @@ snapshots: media-typer@0.3.0: {} + memorystream@0.3.1: {} + merge-descriptors@1.0.3: {} merge2@1.4.1: {} @@ -7501,6 +7592,11 @@ snapshots: natural-compare@1.4.0: {} + ndarray@1.0.19: + dependencies: + iota-array: 1.0.0 + is-buffer: 1.1.6 + negotiator@0.6.3: {} negotiator@0.6.4: {} @@ -7585,6 +7681,8 @@ snapshots: package-json-from-dist@1.0.1: {} + pako@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -8242,8 +8340,6 @@ snapshots: stderr-lib@2.1.0: {} - stderr-lib@2.2.0: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8397,12 +8493,12 @@ snapshots: dependencies: punycode: 2.3.1 - tree-kill@1.2.2: {} - ts-api-utils@1.4.3(typescript@5.7.2): dependencies: typescript: 5.7.2 + ts-mixer@6.0.4: {} + tslib@2.8.1: {} tsx@4.19.2: @@ -8694,8 +8790,6 @@ snapshots: zod@4.3.4: {} - zod@4.3.6: {} - zustand@5.0.11(@types/react@18.3.28)(react@18.3.1): optionalDependencies: '@types/react': 18.3.28