diff --git a/README.md b/README.md
index 934ec50..2420857 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,12 @@ This repository contains API for global DBeaver ecosystem.
OSGI APIs and Service for DataDam integration
Used by DBeaver, dbvr, CloudBeaver and by DataDam itself
+## Remote DAM API
+
+[`apis/com.dbeaver.datadam.dam.remote.api`](apis/com.dbeaver.datadam.dam.remote.api/README.md) contains the Remote DAM
+contracts, clients and transport adapters, together with the messaging implementation. Java packages are
+`com.dbeaver.datadam.dam.remote.api.*` and `com.dbeaver.messaging.*`; messaging is not a separate Maven or OSGi module.
+
## SSO API
`apis/com.dbeaver.datadam.sso.api` exports the shared SSO contracts in
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md b/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md
new file mode 100644
index 0000000..cb933f8
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/MESSAGING.md
@@ -0,0 +1,284 @@
+# Messaging: request/reply and typed RPC
+
+The `com.dbeaver.messaging.requestreply` and `com.dbeaver.messaging.rpc` packages live in the
+`com.dbeaver.datadam.dam.remote.api` module. They supply transport-independent request/reply and typed RPC for Java 21.
+These packages do not depend on DataDam, HTTP, WebSocket, JSON, Spring, or Eclipse runtime services; their only non-JDK
+production dependency supplies nullability annotations. Unsupported RPC methods are logged using the JDK logging API.
+DataDam adapters and codecs live in separate packages in the same module.
+
+## Usage
+
+Create an endpoint over a transport already scoped to the intended peer and logical request topic:
+
+```java
+try (var requests = MessagingRequestReplyEndpoint.open(transport)) {
+ var response = requests.request(payload);
+}
+```
+
+The receiving peer installs one handler on its own endpoint:
+
+```java
+try (var requests = MessagingRequestReplyEndpoint.open(transport);
+ var registration = requests.handle(request -> process(request))) {
+ serveApplication();
+}
+```
+
+An endpoint can initiate and handle requests concurrently. `request` blocks the calling thread and returns the response
+payload, or null for successful completion without a value. Use a worker or virtual thread. Incoming handlers run
+on the receiving thread by default. The endpoint owns no executor; the transport adapter chooses an execution
+boundary when its receiving loop must remain nonblocking. Future objects are internal.
+
+`MessagingRequestReplyEndpoint
` leaves the payload representation to the application and transport adapter. For example,
+`P` can be a string, an immutable application value, or an encoded JSON object. The core does not copy arbitrary `P`
+values: payloads must remain immutable while a request is in use, or the adapter must take defensive snapshots.
+
+The default `MessagingRequestReplyOptions` use a 30-second deadline, at most 256 outgoing requests, and 64 concurrent incoming
+handlers. Supply options to `open`, or a deadline to an individual `request(payload, timeout)` call. Deadlines are
+measured with monotonic time and include local sending. The handler limit has no waiting queue: saturation replies
+with `OVERLOADED`. Outgoing saturation fails locally without sending.
+The handler slot covers application-handler execution and is released before response sending. Each transport adapter
+separately bounds its encoding and outstanding send work.
+
+## Completion and failure
+
+Pending correlation is installed before sending, including when the adapter delivers an immediate response before
+its send stage completes. Request IDs and reply endpoint IDs are independent UUIDs. A response must match both.
+Unknown, late, and duplicate responses do not complete another request.
+
+`MessagingRequestReplyException.reason()` distinguishes local closure, disconnection, capacity rejection, send failure, timeout,
+and remote errors. `remoteError()` identifies `NO_HANDLER`, `OVERLOADED`, or `HANDLER_FAILED` for remote errors. Raw
+request/reply handler exception classes, messages, and stack traces are not sent to the peer; the typed RPC layer below
+adds its own diagnostic error payload. Local send failures retain their causes.
+
+Interruption propagates as `InterruptedException` and releases the pending slot. A thread already interrupted when
+calling `request` does not send. Timeout, interruption, or a send error can leave the remote outcome unknown: none
+promises remote cancellation or makes a non-idempotent request safe to repeat. The library never retries requests,
+replays results, or provides exactly-once execution. Duplicate requests still being handled are ignored; completed
+request IDs are not retained as a replay history.
+
+Closing a handler registration removes only that handler; already accepted work can finish. The returned registration
+is identity-scoped, so closing it again cannot remove a replacement handler. Closing the endpoint fails pending calls,
+removes its transport subscription, and interrupts accepted work. It does not close the borrowed physical transport.
+Closing is idempotent and does not wait for application code that ignores interruption.
+
+A disconnect notification fails pending calls and invalidates replies from work accepted before that notification.
+Handlers are interrupted, but continue counting against capacity until their execution actually stops. The receiving
+subscription can stay installed across reconnects. New calls use the adapter's normal send readiness checks. If an
+adapter cannot observe peer disconnection, outstanding calls expire through their deadlines instead.
+
+## Transport adapter contract
+
+Implement `MessagingRequestReplyTransport
` to integrate a message transport:
+
+- `subscribe(endpointId, listener)` establishes receiving routes before returning. Requests go to one designated
+ handler endpoint; responses go to the original request's `replyTo` endpoint. A transport may limit an adapter
+ instance to one endpoint subscription.
+- Failed subscription establishment must roll back every partially installed route and owned resource.
+- `send(message)` is nonblocking and returns a bounded transport send stage. Completing that stage confirms sending,
+ not remote business completion. A response can arrive before this stage completes.
+- The adapter retains peer scope, verifies the source of received frames, and uses `replyTo` only as a logical reply
+ destination. Correlation UUIDs do not replace authentication or account/peer authorization.
+- Disconnection is reported through `listener.onDisconnect()`. Incoming callbacks can be concurrent and can occur
+ synchronously during subscription establishment or sending.
+- `executeRequest(request)` runs admitted incoming requests inline by default, outside the endpoint lock. An adapter
+ can override it to use a borrowed executor or platform job service. It must execute the supplied task or throw
+ `RejectedExecutionException`; the core maps rejection to `OVERLOADED`. The core never closes that execution service.
+ Responses and disconnects do not use this dispatch hook. A serialized receiver must dispatch requests elsewhere
+ if handlers can wait synchronously for replies arriving through that same receiver.
+- Closing the returned `MessagingSubscription` removes receiving routes and releases adapter-owned resources. It must
+ leave a caller-owned physical session available to other consumers.
+- The adapter owns wire validation, payload snapshots, message size limits, and bounded transport send work. It must
+ not introduce automatic retries or an offline business-message queue.
+
+Transport write failure or a lost reply can prevent a caller from seeing a completed remote operation. Response send
+failure is not retried. An already claimed transport send can finish while closure or disconnection is taking place.
+
+## Typed RPC
+
+Both peers use the same classes. A local service registration and a proxy for a remote service are independent:
+neither requires a service in the reverse direction, and either peer can initiate calls after transport establishment.
+There is no client/server distinction in the RPC API.
+
+The application shares an interface between peers and supplies its implementation on the receiving side:
+
+```java
+public interface Documents {
+ Document load(DocumentRequest request);
+ void save(SaveRequest request);
+}
+```
+
+The session owner creates one reusable endpoint over a topic-scoped transport factory. The receiver supplies
+its own codec when registering an implementation:
+
+```java
+try (var rpc = MessagingRpcEndpoint.open(transports);
+ var registration = rpc.register("documents", Documents.class, implementation, codec)) {
+ serveApplication();
+}
+```
+
+The caller creates its own endpoint over the corresponding transport and obtains a proxy:
+
+```java
+try (var rpc = MessagingRpcEndpoint.open(transports)) {
+ Documents documents = rpc.proxy("documents", Documents.class, codec);
+ Document document = documents.load(request);
+ documents.save(saveRequest);
+}
+```
+
+An owner can provide a default codec with `MessagingRpcEndpoint.open(transports, defaultCodec)`. Its consumers can then use
+`rpc.proxy("documents", Documents.class)` and `rpc.register("documents", Documents.class, implementation)`.
+The overloads with an explicit codec override that fallback only for the new proxy or registration. The core does
+not construct a serialization framework. Without a configured default, no-codec overloads fail before opening routes.
+
+The same endpoint can register other local services or obtain proxies for services on the opposite peer. The supplied
+topic is the actual transport topic, not a service key inside another lane. The factory opens a request/reply transport
+for each topic on demand; repeated use shares its local topic scope. A topic has at most one local implementation,
+and different topics can use the same interface with different implementations and codecs. Topic naming and recipient
+subscriptions belong to the application. Proxy construction establishes local receiving routes but does not contact
+the remote implementation or prove service presence.
+Opening a topic can establish subscriptions synchronously; use a worker thread when the adapter performs network I/O.
+
+Proxy methods block the calling thread. They may run in concurrent HTTP request handlers or worker/virtual threads,
+but not on an event loop or serialized receiver needed to deliver their replies.
+
+`MessagingRpcEndpoint` has no payload type parameter. Its transport factory supplies topic-scoped
+`MessagingRequestReplyTransport>` adapters. Each proxy and registration supplies a codec compatible with that
+payload representation. Business code receives an ordinary `MessagingRpcEndpoint` and uses typed interfaces. The transport
+adapter encodes the neutral protocol envelope; the local codec encodes application arguments and results. A TCP, WebSocket,
+long-polling, or in-memory transport can implement this interface without changing RPC code. No WebSocket types or
+JSON library are used here.
+
+Requests use `MessagingRpcMessage.request(operation, arguments)`. The `arguments` list is an immutable positional
+snapshot that preserves null entries, and the request's `payload` is null. Responses and errors have an empty
+`arguments` list; a response result is carried in `payload`.
+
+### Application-owned registrations and callers
+
+Obtain an endpoint once from the application's session owner, then pass it to ordinary Java objects. Registration
+and proxy creation are runtime operations and do not require Spring or a separate bean for each business interface.
+
+```java
+public final class DocumentReceiver {
+ private final MessagingRpcEndpoint rpc;
+ private final MessagingRpcCodec> documentCodec;
+
+ public DocumentReceiver(MessagingRpcEndpoint rpc, MessagingRpcCodec> documentCodec) {
+ this.rpc = rpc;
+ this.documentCodec = documentCodec;
+ }
+
+ public MessagingSubscription enable(Documents implementation) {
+ return rpc.register("documents", Documents.class, implementation, documentCodec);
+ }
+}
+```
+
+The opposite peer uses its own endpoint in a separate caller:
+
+```java
+public final class DocumentCaller {
+ private final MessagingRpcEndpoint rpc;
+ private final MessagingRpcCodec> documentCodec;
+
+ public DocumentCaller(MessagingRpcEndpoint rpc, MessagingRpcCodec> documentCodec) {
+ this.rpc = rpc;
+ this.documentCodec = documentCodec;
+ }
+
+ public Document load(DocumentRequest request) {
+ Documents documents = rpc.proxy("documents", Documents.class, documentCodec);
+ return documents.load(request);
+ }
+}
+```
+
+Keep the receiving `MessagingSubscription` while the implementation is available, then close it to unregister. A proxy
+is an ordinary business interface, not a registration handle, and requires no separate close. Application code sharing
+an endpoint closes its own registrations; the session owner closes the endpoint when its scope ends. Owners can
+use `isClosed()` to reject new use once closure starts, and `isCloseComplete()` before releasing a cached endpoint's
+scope reservation. The latter becomes true after handler and receiving-subscription cleanup finishes, including when
+cleanup throws. A repeated `close()` does not wait for another thread's ongoing cleanup.
+
+### Contracts and codecs
+
+- Contracts are public, nonsealed interfaces with at least one abstract operation. Methods can take zero, one, or multiple
+ arguments, including varargs.
+ Return values can be ordinary reference or primitive types, or `void`. Reference arguments and results can be null.
+- Inherited abstract operations and overloads are supported. Operation IDs combine the method name and its erased
+ argument types in declaration order as `methodName(type1,type2,...)`; a zero-argument method uses `foo()`.
+ A varargs parameter uses its array type and occupies one argument position. Only methods from the registered interface
+ can be invoked. Incoming names never trigger class loading or arbitrary reflective lookup on the implementation.
+- Concrete generic argument and result types, such as `List`, reach the codec as `Type` values for each argument
+ and result. Methods with unresolved type variables, generic methods, default methods, and `Future`/`CompletionStage`
+ returns are logged and skipped during proxy construction or registration; supported methods remain available.
+ Calling a skipped method on a proxy fails locally with `IllegalArgumentException` without encoding or sending a request;
+ incoming calls to skipped operations receive `UNKNOWN_OPERATION`. Contracts with no usable operations are rejected.
+ Contracts must still be public, nonsealed interfaces without unresolved interface type parameters. Generic-parent
+ substitutions are not inferred; unresolved erased bridges remain invalid. Inherited operations with ambiguous generic
+ argument or result types must be explicitly redeclared in the public contract.
+- `MessagingRpcCodec.encode(value, type)` and `decode(payload, type)` are selected per proxy or implementation registration
+ and invoked independently for each argument in declaration order and for the result. Zero-argument calls do not invoke
+ the argument codec; varargs are encoded and decoded as one array value. Implementations must handle
+ nulls and concurrent calls. Serialization remains application-owned; the RPC core does not construct application
+ types or select a serialization framework. Payload immutability and snapshot requirements are the same as request/reply.
+ Peers must use compatible contracts and codecs; no automatic contract negotiation or class transfer is performed.
+- Implementations may receive concurrent calls and must manage their own application state safely. Incoming execution
+ uses the existing bounded request/reply handlers; the RPC layer adds no executor or unbounded message queue.
+- `equals`, `hashCode`, and `toString` on a proxy are local identity operations and do not send requests or call codecs.
+
+### Failure and ownership
+
+`void` still waits for remote handler completion and a successful reply. It is not fire-and-forget, and its response
+does not invoke the application codec. `MessagingRpcException` is unchecked, so a business interface does not need a library
+exception declaration. Its reason distinguishes timeout, interruption, disconnection, overload, closure, send failure,
+codec failure, invalid response, and remote failure. Caller interruption restores the interrupt flag.
+
+An absent logical service returns `UNKNOWN_SERVICE`; an absent operation returns `UNKNOWN_OPERATION`. Invalid arguments,
+handler failures, and remote codec failures retain their fixed error codes. Handler and remote codec exceptions also
+carry immutable `MessagingRpcExceptionDetails`: the exception class name (`type`), nullable original `message`, formatted
+`stackTrace`, and a `truncated` flag. The receiving side formats the trace where the failure occurs, including causes,
+suppressed exceptions, and circular-reference markers. Reflection's `InvocationTargetException` is unwrapped before capture.
+
+The caller receives `MessagingRpcException` with `reason() == REMOTE_ERROR`, the existing `remoteError()` code, and optional
+`remoteException()` details. Its message includes the remote type and message. Its cause is a local diagnostic wrapper
+with the remote text and no invented local frames, so ordinary exception logging includes both the local call stack and
+the remote trace. Neither the original `Throwable` nor its object graph crosses the transport. The caller never loads or
+instantiates the named remote class. Application codecs handle neither exception details nor diagnostic text.
+
+Type names are limited to 256 UTF-16 code units, messages to 1,024, and stack traces to 8,192. Formatting stops when the
+trace budget is exhausted rather than accumulating an unlimited string first. Truncation of any field, or incomplete
+formatting, sets `truncated`; valid surrogate pairs are not split at a truncation boundary. These text bounds leave room
+for JSON escaping and the surrounding DataDam frame under its default 64 KiB message limit. Diagnostic contents are not
+redacted or replaced with a generic message. A code-only error has null `remoteException()`.
+
+Declared application exceptions are not reconstructed on the caller. A peer with no receiving endpoint can only be
+detected through transport failure or timeout; an error reply lost in transport cannot expose its remote diagnostics.
+Local failures retain their original causes.
+
+`MessagingRpcEndpoint.open(transports, options)` accepts the existing `MessagingRequestReplyOptions` for default deadlines and shared
+capacity. Without explicit options, the default timeout is 30 seconds. Each proxy can override the request timeout
+with a positive `Duration` representable in nanoseconds:
+
+```java
+Documents documents = rpc.proxy("documents", Documents.class, Duration.ofMinutes(2));
+Documents customDocuments = rpc.proxy("documents", Documents.class, documentCodec, Duration.ofMinutes(2));
+```
+
+Overloads without a timeout retain the endpoint default. A timeout is fixed for that proxy and does not change other
+proxies, registrations, or shared topic scopes. Multiple proxies for the same interface and topic can use different
+timeouts without creating another remote implementation or changing the business interface. Topic establishment and
+capacity still use the endpoint options.
+
+The timeout covers each underlying request/reply call, not arbitrary application codec execution. Codecs should complete
+promptly. Timeout or interruption leaves the remote outcome potentially unknown; late replies are ignored, and the RPC
+layer adds no retries or remote cancellation promises.
+
+Closing a service registration removes only that local implementation. It does not close the endpoint or its proxies,
+and work already dispatched to the implementation can finish. Repeatedly closing an old registration cannot remove a
+replacement. Closing the RPC endpoint invalidates its proxies, clears service registrations, and closes its underlying
+request/reply scopes, failing pending calls. The borrowed physical transport remains open.
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF b/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF
new file mode 100644
index 0000000..512e18a
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/META-INF/MANIFEST.MF
@@ -0,0 +1,21 @@
+Manifest-Version: 1.0
+Bundle-ManifestVersion: 2
+Bundle-Vendor: DBeaver Corp
+Bundle-Name: DataDam Remote DAM API
+Bundle-SymbolicName: com.dbeaver.datadam.dam.remote.api
+Bundle-Version: 0.0.17.qualifier
+Bundle-RequiredExecutionEnvironment: JavaSE-21
+Export-Package: com.dbeaver.datadam.dam.remote.api,
+ com.dbeaver.datadam.dam.remote.api.client,
+ com.dbeaver.datadam.dam.remote.api.exception,
+ com.dbeaver.datadam.dam.remote.api.model,
+ com.dbeaver.datadam.dam.remote.api.requestreply,
+ com.dbeaver.datadam.dam.remote.api.rpc,
+ com.dbeaver.datadam.dam.remote.api.transport,
+ com.dbeaver.messaging.requestreply,
+ com.dbeaver.messaging.rpc
+Bundle-ActivationPolicy: lazy
+Require-Bundle: org.jkiss.utils,
+ com.dbeaver.rest.client;visibility:=reexport,
+ com.google.gson
+Automatic-Module-Name: com.dbeaver.datadam.dam.remote.api
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/README.md b/apis/com.dbeaver.datadam.dam.remote.api/README.md
new file mode 100644
index 0000000..c7f1ab0
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/README.md
@@ -0,0 +1,249 @@
+# Remote DAM API
+
+The `com.dbeaver.datadam.dam.remote.api` module contains Remote DAM WebSocket contracts, backend-only GraphQL clients,
+and transport-independent messaging packages. A Remote DAM peer can be a Desktop application or a future Managed cluster.
+Its session identifies the authenticated DAM transport scope, independently of any database connection.
+
+Java package names are `com.dbeaver.datadam.dam.remote.api.*` and `com.dbeaver.messaging.*`.
+See [Messaging](MESSAGING.md) for the neutral request/reply and typed RPC contracts.
+
+## Session identity and handshake
+
+The WebSocket handshake sends `DDRemoteDamHandshakeRequest` with `appDeploymentId`, `appInstanceId`, `product`, and
+`version`. `DDRemoteDamHandshakeResponse` returns the assigned `damSessionId`. Wire control types remain `hello` and
+`ready`, and the WebSocket path remains `/ws`. Control messages have `type` and an object `payload`; application
+messages additionally have `topic`. Application messages may use any nonblank type, including the control type names.
+
+`DDRemoteDamSessionInfo` contains `appDeploymentId`, `appInstanceId`, and `damSessionId`.
+`DDRemoteDamSession` adds `product` and `version` for registry presentation. All fields are nonblank strings.
+Concurrent sessions may share application deployment and instance IDs; consumers must use `damSessionId` to distinguish
+them. Product and version are peer-supplied presentation metadata, not authenticated identity or authorization claims.
+
+## Internal GraphQL contract
+
+`DDRemoteDamClient` posts JSON requests to `/internal/graphql` on the configured relay service root URL.
+The service implementation must expose this contract; adding a client alone does not deploy the endpoint.
+
+```graphql
+scalar JSON
+
+type Query {
+ remoteDamSessions(accountId: ID!): [RemoteDamSession!]!
+ remoteDamSessionAccount(damSessionId: ID!): ID
+}
+
+type Mutation {
+ sendRemoteDamMessage(accountId: ID!, damSessionId: ID!, message: RemoteDamMessageInput!): Boolean!
+ registerRemoteDamSubscription(input: RemoteDamSubscriptionInput!): Int!
+ unregisterRemoteDamSubscription(subscriberId: String!, subscriptionId: ID!): Boolean!
+}
+
+type RemoteDamSession {
+ appDeploymentId: String!
+ appInstanceId: String!
+ damSessionId: ID!
+ product: String!
+ version: String!
+}
+
+input RemoteDamMessageInput {
+ topic: String!
+ type: String!
+ payload: JSON!
+}
+
+input RemoteDamSubscriptionInput {
+ subscriberId: String!
+ subscriptionId: ID!
+ accountId: ID!
+ damSessionId: ID
+ topic: String!
+ callbackUrl: String!
+ callbackSecret: String!
+}
+```
+
+- `accountId` is a UUID, mandatory for account-wide session lists, sends, and subscription registration. Sends use the
+ account and DAM session ID together.
+- `remoteDamSessionAccount` resolves a unique session ID to the account from its verified identity. It returns null
+ for a missing or not-ready session; the client reports this as checked `DDRemoteDamException`. This backend-only lookup
+ does not authorize end-user access to that account.
+- Session lists are immutable client-side snapshots of currently ready transport scopes, not persisted installations
+ or session history.
+- Message payloads are JSON objects passed through GraphQL variables, never interpolated into query text or encoded as
+ JSON strings. `DDRemoteDamMessage` takes defensive payload snapshots.
+- A send result of `true` confirms completion of the server's WebSocket write, not peer receipt or processing.
+ `false` means no ready session exists for that account and session ID, including a session belonging to another account.
+- A session closing after lookup, queue rejection, an invalid message, or a write failure produces a GraphQL error.
+ HTTP errors, GraphQL errors including partial data, and malformed responses become `DDRemoteDamException`.
+- The client never retries mutations or queues messages offline. After timeout or network failure, delivery may be
+ unknown; callers must not blindly replay non-idempotent events.
+- Sessions currently live in one relay process. Until owner-aware routing exists, calls must reach the process owning
+ the session; a missing result from another replica does not prove global offline status.
+
+## Authentication and configuration
+
+`DDRemoteDamClient` takes a service root URL and the Remote DAM service secret. It never logs in or acquires a peer token.
+The URL must use HTTP or HTTPS, have no credentials, query, fragment, or non-root path, and use a valid port.
+HTTP is supported for trusted private service networks; use HTTPS across untrusted networks.
+
+The service secret contains 32–512 printable, non-space ASCII characters. It authenticates backend requests and callbacks
+through `X-DD-Service-Token`. Subscription registration also carries that same secret in its `callbackSecret` variable;
+protect that backend-only request and never log its body. Secrets must not appear in URLs or GraphQL query text.
+Redirects are disabled. HTTP establishment uses the shared REST client's 5-second connect timeout; responses have a
+10-second deadline. HTTP error bodies, GraphQL error details, and malformed response contents are not copied into client
+exceptions.
+
+The secret authenticates a trusted backend, not an end user. Backends exposing operations to users must derive `accountId`
+from authorized account context. A DAM session ID alone is not end-user authorization; expose only authorized account
+selections. The server validates the secret before internal GraphQL execution and fails closed when it is missing or
+invalid. Browser sessions and peer JWTs must not grant access. Keep the service secret in backend runtime configuration;
+it is separate from the Account backchannel secret used to verify peer JWTs.
+
+```java
+var client = new DDRemoteDamClient(serviceUrl, serviceSecret);
+var sessions = client.getSessions(accountId);
+boolean written = client.sendMessage(accountId, damSessionId, message);
+```
+
+Use one configured client per service endpoint. No deployment-specific URL or secret is packaged here.
+`getSessionsAsync`, `getSessionAccountAsync`, `sendMessageAsync`, `registerSubscriptionAsync`, and
+`unregisterSubscriptionAsync` use the JDK HTTP client's nonblocking API without a wrapper executor. Synchronous methods
+remain available. The deadline includes the response body. Cancelling a stage attempts to stop the HTTP exchange but
+does not promise cancellation of an operation already running remotely.
+
+## Backend callback subscriptions
+
+`registerSubscription(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret)` returns
+the lease in seconds. The receiving process generates the UUID `subscriptionId`; repeated registration with the same
+scope and callback renews it. A null `damSessionId` subscribes to matching account messages, including future sessions.
+A non-null ID filters one physical session and does not follow a replacement session.
+
+Callback service root URLs and secrets follow the same validation as relay URLs and service secrets. Only authenticated
+trusted backends may register callbacks. Restrict outbound relay network access and use HTTPS outside trusted networks.
+The relay keeps registrations in memory, with no configured subscriber list or separate service-registration step.
+The subscriber ID identifies the receiver and does not determine business topics.
+
+`DDRemoteDamCallbackClient` posts to `/internal/remote-dam/graphql`. Callback variables are `subscriptionId`, `accountId`,
+`session`, and `message`. The session input is `RemoteDamSessionInfoInput` with `appDeploymentId`, `appInstanceId`, and
+`damSessionId`. The mutation is `deliverRemoteDamMessage(subscriptionId: ..., accountId: ..., session: ..., message: ...)`.
+
+The receiver invokes its listener on the HTTP request thread and acknowledges after it returns. Successful acknowledgement
+extends the lease. Concurrent callbacks may invoke a listener concurrently without ordering guarantees; listeners must be
+thread-safe. The RPC reply carries the business result. `unregisterSubscription` handles cancellation and cleanup of
+unknown local registrations. The relay stops starting callbacks on cancellation or expiry; already started callbacks may
+finish. Failed callbacks are not replayed or retried. In-flight callbacks are bounded without a per-subscription executor
+or mailbox. `deliverAsync` is nonblocking, with a default 30-second deadline including listener execution; explicit
+durations are supported. Timeout does not promise remote handler cancellation.
+
+Spring services use the shared `com.dbeaver.datadam.dam.remote.base` server module and its
+`DDRemoteDamClientConfiguration`. It supplies callback reception, local dispatch, subscription cancellation, and idle
+renewal. Configure `datadam.remote-dam-client.service-url`, `datadam.remote-dam-client.service-secret`,
+and `datadam.remote-dam-client.callback-url`. There is no separate callback-secret setting. The configured service secret
+is used for requests, callback authentication, and the registration's `callbackSecret` field.
+
+Select `subscriptions.forSession(accountId, damSessionId)` or `subscriptions.forAccount(accountId)`, then subscribe by
+topic. The protocol retains account and session scope for ownership checks. Handlers do not renew registrations.
+Every callback destination must reach the exact receiving process owning its local registration.
+Peer-side subscriptions remain local: authenticated messages are dispatched to local topic listeners without announcing
+handlers to the relay.
+
+## Channels and request/reply
+
+`DDRemoteDamChannel.getStatus()` returns immutable `DDRemoteDamChannelStatus` in `DISCONNECTED`, `CONNECTING`, `CONNECTED`,
+or `CLOSED` state. Only `CONNECTED` carries `session()` information; `isReady()` tests readiness.
+`observeState(observer)` delivers the current state and ordered changes without a snapshot/subscription race. Its returned
+subscription closes only that observer. Initial notification can be synchronous; later callbacks run on the notifying
+thread. `addSessionListener` remains transitions-only, calling `onConnected` and `onDisconnected` with session information.
+
+`DDRemoteDamRequestReplyTransport` adapts a scoped channel to `MessagingRequestReplyEndpoint`:
+
+```java
+var transport = DDRemoteDamRequestReplyTransport.forChannel(channel, "documents");
+try (var requests = MessagingRequestReplyEndpoint.open(transport)) {
+ var response = requests.request(payload);
+}
+```
+
+A backend can instead obtain its transport from `subscriptions.forSession(accountId, damSessionId).requestReply("documents")`.
+Open a physical-channel endpoint after readiness. Opening while disconnected or closed fails without retaining
+subscriptions. Endpoints can stay installed across reconnects, but pending calls fail at disconnection and are not replayed.
+The peer installs `requests.handle(payload -> process(payload))`. Use distinct topics for independently handled services.
+Each endpoint installs its private reply route before accepting calls; replies return to the originating backend process.
+Null responses represent successful completion without a value. This lower layer exposes protocol error codes without
+application exception details; typed RPC adds its own diagnostics.
+
+The bridge message type remains `datadam.requestreply.v1`. Envelopes contain `requestId`, `replyTo`, `type`, and either
+`data` or `error`. `replyTo` is an endpoint UUID, not a backend address. Private topics remain
+`datadam.reply.v1..` and fit metadata limits for maximum-length topics.
+Closing an endpoint removes both receiving routes and owned work without closing a shared channel or subscription facade.
+Backend request/reply is exposed only for `forSession`, with no implicit selection from account subscriptions.
+
+`request` blocks a worker or virtual thread. Ordinary sends and HTTP callback acknowledgements have transport-only
+meaning. Backend sends use nonblocking HTTP, limited to 128 in flight without a send pool or waiting queue. Facade closure
+rejects new sends and invalidates later completions; an already started HTTP send can finish. Physical channels notify
+disconnect immediately; backend callbacks have no physical session-state feed, so pending calls expire by deadline.
+Timeout or interruption can leave remote outcome unknown; requests and responses are never automatically retried.
+
+The direct-channel adapter orders messages and disconnections through one bounded receiving mailbox. Source validation
+and mailbox insertion share a lifecycle lock; callbacks run outside it. A newly visible source inserts its generation
+boundary before its first message. Delayed observers read current status rather than reverting to an old source.
+The mailbox holds 128 callbacks without an executor. Excess frames are dropped; disconnection takes priority over a full
+batch. Listener failure closes the receiving routes. Backend callbacks invoke listeners directly on concurrent HTTP threads.
+The neutral endpoint owns no executor. Blocking handlers must stay off a serialized receiver needed for their replies.
+
+## Typed RPC
+
+`com.dbeaver.messaging.rpc` supplies typed proxies and implementation registration independently of DataDam and WebSocket.
+`DDRemoteDamRpcTransport` adapts request/reply to `MessagingRpcMessage`. Application serialization is selected
+through a codec, for example `new DDRemoteDamGsonRpcCodec(new Gson())`. The codec retains the application's Gson adapters
+and naming policies, preserves declared generic types, and handles each argument independently. A varargs array is one
+argument; JSON null is a nullable payload. Each proxy and registration can use a different compatible codec.
+
+```java
+try (var rpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(channel), codec);
+ var registration = rpc.register("dam.projects", DamProjects.class, implementation)) {
+ ServerDocuments documents = rpc.proxy("server.documents", ServerDocuments.class);
+ serveApplication(documents);
+}
+```
+
+Backend scopes wrap `scope.requestReply(topic)` in `DDRemoteDamRpcTransport` and use the same endpoint API. An optional
+borrowed `requestExecutor` runs admitted incoming requests; otherwise the receiving thread runs them. Responses and
+disconnects never use that executor. Application/platform owners must keep blocking handlers off WebSocket delivery.
+The transport owns no execution service.
+
+The developer's topic is the actual request topic. Routes open lazily and are locally reused, with no automatic Account,
+Storage, or service prefix. Registering locally does not register a remote service. Arrange the intended receiver's
+subscription before calling; duplicate backend receivers are not service discovery. Exact callback-owner routing still
+applies.
+
+Backend `DDRemoteDamRpc` owners expose `getSessions(accountId)`, `forSession(damSessionId)`, and `closeSession(damSessionId)`.
+Listing returns an immutable snapshot without opening endpoints. Selection is application-owned; opening resolves the
+account through the authenticated relay API and preserves ownership checks. Known session IDs work without listing or
+local discovery caches. Repeated lookup shares an open endpoint; identifiers are not repeated on each business call.
+Session owners provide a default `DDRemoteDamGsonRpcCodec`; explicit codec overloads override it for one binding only.
+Proxy timeout overloads accept `Duration`, without changing other proxies or the shared session; the default is 30 seconds
+unless owner options specify otherwise.
+
+Obtain an endpoint from the application's ready session owner and pass it to ordinary objects. Endpoint access does not
+start login or wait for readiness. Register implementations at runtime and retain their registration handles until they
+should stop accepting calls. Proxies need no separate close. The session owner owns the shared endpoint. Transient
+reconnects retain endpoints and registrations; authentication identity changes and shutdown invalidate them. Replacing a
+closed endpoint does not transfer registrations. Backend owners close missing sessions after successful reconciliation;
+failed registry reads preserve endpoints. Asynchronous opening results are shared while callers wait with a timeout.
+Reconciliation and renewal use platform scheduling, without owner-created executors. Owners and caches remain process-local.
+
+The inner envelope remains `protocol: "dbeaver.rpc.v2"`, `type`, and `data`, with `operation` on requests or a fixed `error`
+code on failures. Request data is positional: `[]`, `[value]`, `[null]`, or `[value1, value2]`; varargs can be `[["a", "b"]]`.
+Error `exception` is null or an object with string `type`, nullable `message`, string `stackTrace`, and boolean `truncated`.
+Causes and suppressed exceptions are formatted remotely; no `Throwable` is serialized and application codecs are not used.
+Each argument and response can be any JSON value, while the outer topic message remains an object. Existing correlation,
+source validation, and authentication still apply. Malformed envelopes are ignored without exposing raw contents.
+
+Framing, snapshot copying, and outstanding sends are bounded to 128 operations without a waiting queue. Cancellation does
+not release a send slot until its underlying operation finishes. Business contracts use ordinary synchronous methods;
+`void` waits for completion. Failures become unchecked `MessagingRpcException`; remote handler/codec failures preserve
+their codes and bounded `remoteException()` diagnostics, also exposed through a local diagnostic cause for logging.
+Bounds are 256 UTF-16 units for type, 1,024 for message, and 8,192 for trace. `truncated` marks incomplete details; code-only
+errors have null diagnostics. See [Messaging](MESSAGING.md) for contract shapes, concurrency, ownership, and timeout semantics.
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/build.properties b/apis/com.dbeaver.datadam.dam.remote.api/build.properties
new file mode 100644
index 0000000..e5d65d5
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/build.properties
@@ -0,0 +1,4 @@
+source.. = src/main/java
+output.. = target/classes/
+bin.includes = .,\
+ META-INF/
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/pom.xml b/apis/com.dbeaver.datadam.dam.remote.api/pom.xml
new file mode 100644
index 0000000..f990c5b
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/pom.xml
@@ -0,0 +1,63 @@
+
+
+ 4.0.0
+
+ com.dbeaver.datadam
+ api
+ 0.0.17-SNAPSHOT
+ ../pom.xml
+
+
+ com.dbeaver.datadam.dam.remote.api
+ eclipse-plugin
+ DataDam Remote DAM API
+
+
+
+ com.dbeaver.common
+ org.jkiss.utils
+
+
+ com.google.code.gson
+ gson
+
+
+ com.dbeaver.common
+ com.dbeaver.rest.client
+ ${dbeaver.common.version}
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+
+
+ ${project.basedir}/src/main/java
+ ${project.basedir}/src/test/java
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ test
+ test
+
+ test
+
+
+
+
+
+
+
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java
new file mode 100644
index 0000000..5d55395
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApi.java
@@ -0,0 +1,60 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api;
+
+import org.jkiss.code.Nullable;
+
+import java.net.URI;
+
+public final class DDRemoteDamApi {
+ public static final String GRAPHQL_PATH = "/internal/graphql";
+ public static final String CALLBACK_GRAPHQL_PATH = "/internal/remote-dam/graphql";
+ public static final String HEADER_SERVICE_TOKEN = "X-DD-Service-Token";
+ public static final int MIN_SERVICE_SECRET_LENGTH = 32;
+ public static final int MAX_SERVICE_SECRET_LENGTH = 512;
+ public static final int MIN_SUBSCRIPTION_LEASE_SECONDS = 15;
+ public static final int MAX_SUBSCRIPTION_LEASE_SECONDS = 300;
+ private static final int MAX_SUBSCRIBER_ID_LENGTH = 256;
+ private static final int MAX_SERVICE_URL_LENGTH = 4096;
+
+ private DDRemoteDamApi() {
+ }
+
+ public static boolean isValidSubscriberId(@Nullable String subscriberId) {
+ return subscriberId != null && !subscriberId.isBlank() && subscriberId.length() <= MAX_SUBSCRIBER_ID_LENGTH;
+ }
+
+ public static boolean isValidServiceSecret(@Nullable String secret) {
+ return secret != null && secret.length() >= MIN_SERVICE_SECRET_LENGTH && secret.length() <= MAX_SERVICE_SECRET_LENGTH
+ && secret.chars().allMatch(value -> value >= 33 && value <= 126);
+ }
+
+ public static boolean isValidServiceUrl(@Nullable String serviceUrl) {
+ if (serviceUrl == null || serviceUrl.length() > MAX_SERVICE_URL_LENGTH) {
+ return false;
+ }
+ try {
+ URI uri = URI.create(serviceUrl);
+ return uri.getHost() != null && uri.getUserInfo() == null && uri.getRawQuery() == null && uri.getRawFragment() == null
+ && ("https".equalsIgnoreCase(uri.getScheme()) || "http".equalsIgnoreCase(uri.getScheme()))
+ && (uri.getRawPath().isEmpty() || "/".equals(uri.getRawPath()))
+ && (uri.getPort() == -1 || uri.getPort() >= 1 && uri.getPort() <= 65535);
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java
new file mode 100644
index 0000000..80d1e32
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocol.java
@@ -0,0 +1,143 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api;
+
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeRequest;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeResponse;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import org.jkiss.code.NotNull;
+
+public final class DDRemoteDamProtocol {
+ public static final String WEBSOCKET_PATH = "/ws";
+ public static final String TYPE_HANDSHAKE_REQUEST = "hello";
+ public static final String TYPE_HANDSHAKE_RESPONSE = "ready";
+
+ private static final String FIELD_TOPIC = "topic";
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_PAYLOAD = "payload";
+ private static final String FIELD_APP_DEPLOYMENT_ID = "appDeploymentId";
+ private static final String FIELD_APP_INSTANCE_ID = "appInstanceId";
+ private static final String FIELD_PRODUCT = "product";
+ private static final String FIELD_VERSION = "version";
+ private static final String FIELD_DAM_SESSION_ID = "damSessionId";
+
+ private DDRemoteDamProtocol() {
+ }
+
+ @NotNull
+ public static String encode(@NotNull DDRemoteDamMessage message) {
+ JsonObject envelope = envelope(message.type(), message.payload());
+ envelope.addProperty(FIELD_TOPIC, message.topic());
+ return envelope.toString();
+ }
+
+ @NotNull
+ public static DDRemoteDamMessage decode(@NotNull String message) {
+ JsonObject envelope = parseEnvelope(message);
+ return new DDRemoteDamMessage(
+ readString(envelope, FIELD_TOPIC),
+ readString(envelope, FIELD_TYPE),
+ readPayload(envelope)
+ );
+ }
+
+ @NotNull
+ public static String encodeHandshakeRequest(@NotNull DDRemoteDamHandshakeRequest hello) {
+ JsonObject payload = new JsonObject();
+ payload.addProperty(FIELD_APP_DEPLOYMENT_ID, hello.appDeploymentId());
+ payload.addProperty(FIELD_APP_INSTANCE_ID, hello.appInstanceId());
+ payload.addProperty(FIELD_PRODUCT, hello.product());
+ payload.addProperty(FIELD_VERSION, hello.version());
+ return envelope(TYPE_HANDSHAKE_REQUEST, payload).toString();
+ }
+
+ @NotNull
+ public static DDRemoteDamHandshakeRequest decodeHandshakeRequest(@NotNull String message) {
+ JsonObject payload = readControlPayload(message, TYPE_HANDSHAKE_REQUEST);
+ return new DDRemoteDamHandshakeRequest(
+ readString(payload, FIELD_APP_DEPLOYMENT_ID),
+ readString(payload, FIELD_APP_INSTANCE_ID),
+ readString(payload, FIELD_PRODUCT),
+ readString(payload, FIELD_VERSION)
+ );
+ }
+
+ @NotNull
+ public static String encodeHandshakeResponse(@NotNull DDRemoteDamHandshakeResponse ready) {
+ JsonObject payload = new JsonObject();
+ payload.addProperty(FIELD_DAM_SESSION_ID, ready.damSessionId());
+ return envelope(TYPE_HANDSHAKE_RESPONSE, payload).toString();
+ }
+
+ @NotNull
+ public static DDRemoteDamHandshakeResponse decodeHandshakeResponse(@NotNull String message) {
+ return new DDRemoteDamHandshakeResponse(readString(readControlPayload(message, TYPE_HANDSHAKE_RESPONSE), FIELD_DAM_SESSION_ID));
+ }
+
+ @NotNull
+ private static JsonObject envelope(@NotNull String type, @NotNull JsonObject payload) {
+ JsonObject envelope = new JsonObject();
+ envelope.addProperty(FIELD_TYPE, type);
+ envelope.add(FIELD_PAYLOAD, payload);
+ return envelope;
+ }
+
+ @NotNull
+ private static JsonObject parseEnvelope(@NotNull String message) {
+ try {
+ JsonElement parsed = JsonParser.parseString(message);
+ if (!parsed.isJsonObject()) {
+ throw new IllegalArgumentException("Expected a message object");
+ }
+ return parsed.getAsJsonObject();
+ } catch (JsonParseException e) {
+ throw new IllegalArgumentException("Invalid message JSON", e);
+ }
+ }
+
+ @NotNull
+ private static JsonObject readControlPayload(@NotNull String message, @NotNull String expectedType) {
+ JsonObject envelope = parseEnvelope(message);
+ if (envelope.has(FIELD_TOPIC) || !expectedType.equals(readString(envelope, FIELD_TYPE))) {
+ throw new IllegalArgumentException("Expected a " + expectedType + " message");
+ }
+ return readPayload(envelope);
+ }
+
+ @NotNull
+ private static JsonObject readPayload(@NotNull JsonObject envelope) {
+ JsonElement payload = envelope.get(FIELD_PAYLOAD);
+ if (payload == null || !payload.isJsonObject()) {
+ throw new IllegalArgumentException("Expected a payload object");
+ }
+ return payload.getAsJsonObject();
+ }
+
+ @NotNull
+ private static String readString(@NotNull JsonObject object, @NotNull String field) {
+ JsonElement value = object.get(field);
+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()
+ || value.getAsString().isBlank()) {
+ throw new IllegalArgumentException("Expected a non-blank string for " + field);
+ }
+ return value.getAsString();
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java
new file mode 100644
index 0000000..557dfad
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClient.java
@@ -0,0 +1,102 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.client;
+
+import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi;
+import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import com.google.gson.JsonElement;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletionStage;
+
+public final class DDRemoteDamCallbackClient extends DDRemoteDamGraphQlClient {
+ private static final String DELIVER_FIELD = "deliverRemoteDamMessage";
+ private static final String DELIVER_OPERATION = "DeliverRemoteDamMessage";
+ private static final String DELIVER_MUTATION = """
+ mutation DeliverRemoteDamMessage($subscriptionId: ID!, $accountId: ID!,
+ $session: RemoteDamSessionInfoInput!, $message: RemoteDamMessageInput!) {
+ deliverRemoteDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, session: $session, message: $message)
+ }
+ """;
+
+ public DDRemoteDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret) {
+ this(serviceUrl, serviceSecret, Duration.ofSeconds(30));
+ }
+
+ public DDRemoteDamCallbackClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull Duration deliveryTimeout) {
+ super(serviceUrl, serviceSecret, DDRemoteDamApi.CALLBACK_GRAPHQL_PATH, timeoutMillis(deliveryTimeout));
+ }
+
+ public void deliver(
+ @NotNull UUID subscriptionId,
+ @NotNull UUID accountId,
+ @NotNull DDRemoteDamSessionInfo session,
+ @NotNull DDRemoteDamMessage message
+ ) throws DDRemoteDamException {
+ readDeliveryResult(request(DELIVER_OPERATION, DELIVER_MUTATION,
+ deliveryVariables(subscriptionId, accountId, session, message), DELIVER_FIELD));
+ }
+
+ @NotNull
+ public CompletionStage deliverAsync(
+ @NotNull UUID subscriptionId,
+ @NotNull UUID accountId,
+ @NotNull DDRemoteDamSessionInfo session,
+ @NotNull DDRemoteDamMessage message
+ ) {
+ return requestAsync(DELIVER_OPERATION, DELIVER_MUTATION,
+ deliveryVariables(subscriptionId, accountId, session, message), DELIVER_FIELD,
+ DDRemoteDamCallbackClient::readDeliveryResult);
+ }
+
+ @NotNull
+ private static Map deliveryVariables(
+ @NotNull UUID subscriptionId,
+ @NotNull UUID accountId,
+ @NotNull DDRemoteDamSessionInfo session,
+ @NotNull DDRemoteDamMessage message
+ ) {
+ return Map.of(
+ "subscriptionId", Objects.requireNonNull(subscriptionId).toString(),
+ "accountId", Objects.requireNonNull(accountId).toString(),
+ "session", Objects.requireNonNull(session), "message", Objects.requireNonNull(message)
+ );
+ }
+
+ @Nullable
+ private static Void readDeliveryResult(@NotNull JsonElement result) throws DDRemoteDamException {
+ if (!readBoolean(result)) {
+ throw new DDRemoteDamException("Remote DAM callback was not accepted");
+ }
+ return null;
+ }
+
+ private static int timeoutMillis(@NotNull Duration timeout) {
+ if (timeout.isNegative() || timeout.compareTo(Duration.ofMillis(1)) < 0
+ || timeout.compareTo(Duration.ofMillis(Integer.MAX_VALUE)) > 0) {
+ throw new IllegalArgumentException("Invalid callback delivery timeout");
+ }
+ return (int) timeout.toMillis();
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java
new file mode 100644
index 0000000..499cfc1
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClient.java
@@ -0,0 +1,286 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.client;
+
+import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi;
+import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSession;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletionStage;
+
+public final class DDRemoteDamClient extends DDRemoteDamGraphQlClient {
+ private static final int READ_TIMEOUT_MILLIS = 10_000;
+ private static final String SESSIONS_FIELD = "remoteDamSessions";
+ private static final String SESSION_ACCOUNT_FIELD = "remoteDamSessionAccount";
+ private static final String SEND_FIELD = "sendRemoteDamMessage";
+ private static final String SESSIONS_OPERATION = "RemoteDamSessions";
+ private static final String SESSION_ACCOUNT_OPERATION = "RemoteDamSessionAccount";
+ private static final String SEND_OPERATION = "SendRemoteDamMessage";
+ private static final String SESSIONS_QUERY = """
+ query RemoteDamSessions($accountId: ID!) {
+ remoteDamSessions(accountId: $accountId) {
+ appDeploymentId appInstanceId damSessionId product version
+ }
+ }
+ """;
+ private static final String SESSION_ACCOUNT_QUERY = """
+ query RemoteDamSessionAccount($damSessionId: ID!) {
+ remoteDamSessionAccount(damSessionId: $damSessionId)
+ }
+ """;
+ private static final String SEND_MUTATION = """
+ mutation SendRemoteDamMessage($accountId: ID!, $damSessionId: ID!, $message: RemoteDamMessageInput!) {
+ sendRemoteDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message)
+ }
+ """;
+ private static final String REGISTER_MUTATION = """
+ mutation RegisterRemoteDamSubscription($input: RemoteDamSubscriptionInput!) {
+ registerRemoteDamSubscription(input: $input)
+ }
+ """;
+ private static final String UNREGISTER_MUTATION = """
+ mutation UnregisterRemoteDamSubscription($subscriberId: String!, $subscriptionId: ID!) {
+ unregisterRemoteDamSubscription(subscriberId: $subscriberId, subscriptionId: $subscriptionId)
+ }
+ """;
+
+ public DDRemoteDamClient(@NotNull String serviceUrl, @NotNull String serviceSecret) {
+ super(serviceUrl, serviceSecret, DDRemoteDamApi.GRAPHQL_PATH, READ_TIMEOUT_MILLIS);
+ }
+
+ @NotNull
+ public List getSessions(@NotNull UUID accountId) throws DDRemoteDamException {
+ return readSessions(request(SESSIONS_OPERATION, SESSIONS_QUERY,
+ Map.of("accountId", Objects.requireNonNull(accountId).toString()), SESSIONS_FIELD));
+ }
+
+ @NotNull
+ public CompletionStage> getSessionsAsync(@NotNull UUID accountId) {
+ return requestAsync(SESSIONS_OPERATION, SESSIONS_QUERY,
+ Map.of("accountId", Objects.requireNonNull(accountId).toString()), SESSIONS_FIELD, DDRemoteDamClient::readSessions);
+ }
+
+ @NotNull
+ public UUID getSessionAccount(@NotNull String damSessionId) throws DDRemoteDamException {
+ return readSessionAccount(request(SESSION_ACCOUNT_OPERATION, SESSION_ACCOUNT_QUERY,
+ sessionAccountVariables(damSessionId), SESSION_ACCOUNT_FIELD));
+ }
+
+ @NotNull
+ public CompletionStage getSessionAccountAsync(@NotNull String damSessionId) {
+ return requestAsync(SESSION_ACCOUNT_OPERATION, SESSION_ACCOUNT_QUERY,
+ sessionAccountVariables(damSessionId), SESSION_ACCOUNT_FIELD, DDRemoteDamClient::readSessionAccount);
+ }
+
+ public boolean sendMessage(@NotNull UUID accountId, @NotNull String damSessionId, @NotNull DDRemoteDamMessage message)
+ throws DDRemoteDamException {
+ return readBoolean(request(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, damSessionId, message), SEND_FIELD));
+ }
+
+ @NotNull
+ public CompletionStage sendMessageAsync(
+ @NotNull UUID accountId,
+ @NotNull String damSessionId,
+ @NotNull DDRemoteDamMessage message
+ ) {
+ return requestAsync(SEND_OPERATION, SEND_MUTATION, messageVariables(accountId, damSessionId, message), SEND_FIELD,
+ DDRemoteDamGraphQlClient::readBoolean);
+ }
+
+ public int registerSubscription(
+ @NotNull String subscriberId,
+ @NotNull UUID subscriptionId,
+ @NotNull UUID accountId,
+ @Nullable String damSessionId,
+ @NotNull String topic,
+ @NotNull String callbackUrl,
+ @NotNull String callbackSecret
+ ) throws DDRemoteDamException {
+ return readLease(request("RegisterRemoteDamSubscription", REGISTER_MUTATION,
+ subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret),
+ "registerRemoteDamSubscription"));
+ }
+
+ @NotNull
+ public CompletionStage registerSubscriptionAsync(
+ @NotNull String subscriberId,
+ @NotNull UUID subscriptionId,
+ @NotNull UUID accountId,
+ @Nullable String damSessionId,
+ @NotNull String topic,
+ @NotNull String callbackUrl,
+ @NotNull String callbackSecret
+ ) {
+ return requestAsync("RegisterRemoteDamSubscription", REGISTER_MUTATION,
+ subscriptionVariables(subscriberId, subscriptionId, accountId, damSessionId, topic, callbackUrl, callbackSecret),
+ "registerRemoteDamSubscription", DDRemoteDamClient::readLease);
+ }
+
+ public boolean unregisterSubscription(@NotNull String subscriberId, @NotNull UUID subscriptionId) throws DDRemoteDamException {
+ return readBoolean(request("UnregisterRemoteDamSubscription", UNREGISTER_MUTATION,
+ unsubscriptionVariables(subscriberId, subscriptionId), "unregisterRemoteDamSubscription"));
+ }
+
+ @NotNull
+ public CompletionStage unregisterSubscriptionAsync(@NotNull String subscriberId, @NotNull UUID subscriptionId) {
+ return requestAsync("UnregisterRemoteDamSubscription", UNREGISTER_MUTATION,
+ unsubscriptionVariables(subscriberId, subscriptionId), "unregisterRemoteDamSubscription",
+ DDRemoteDamGraphQlClient::readBoolean);
+ }
+
+ @NotNull
+ private static List readSessions(@NotNull JsonElement result) throws DDRemoteDamException {
+ if (!result.isJsonArray()) {
+ throw invalidResponse();
+ }
+ List sessions = new ArrayList<>();
+ for (JsonElement value : result.getAsJsonArray()) {
+ JsonObject session = readObject(value);
+ sessions.add(new DDRemoteDamSession(
+ readString(session, "appDeploymentId"), readString(session, "appInstanceId"),
+ readString(session, "damSessionId"), readString(session, "product"), readString(session, "version")
+ ));
+ }
+ return List.copyOf(sessions);
+ }
+
+ @NotNull
+ private static UUID readSessionAccount(@NotNull JsonElement result) throws DDRemoteDamException {
+ if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isString()) {
+ throw invalidResponse();
+ }
+ UUID accountId;
+ try {
+ accountId = UUID.fromString(result.getAsString());
+ } catch (IllegalArgumentException e) {
+ throw invalidResponse();
+ }
+ if (!accountId.toString().equalsIgnoreCase(result.getAsString())) {
+ throw invalidResponse();
+ }
+ return accountId;
+ }
+
+ private static int readLease(@NotNull JsonElement result) throws DDRemoteDamException {
+ if (!result.isJsonPrimitive() || !result.getAsJsonPrimitive().isNumber()) {
+ throw invalidResponse();
+ }
+ try {
+ int leaseSeconds = result.getAsBigDecimal().intValueExact();
+ if (leaseSeconds >= DDRemoteDamApi.MIN_SUBSCRIPTION_LEASE_SECONDS
+ && leaseSeconds <= DDRemoteDamApi.MAX_SUBSCRIPTION_LEASE_SECONDS) {
+ return leaseSeconds;
+ }
+ } catch (ArithmeticException | NumberFormatException e) {
+ throw invalidResponse();
+ }
+ throw invalidResponse();
+ }
+
+ @NotNull
+ private static Map sessionAccountVariables(@NotNull String damSessionId) {
+ if (damSessionId.isBlank() || damSessionId.length() > 256) {
+ throw new IllegalArgumentException("Invalid session ID");
+ }
+ return Map.of("damSessionId", damSessionId);
+ }
+
+ @NotNull
+ private static Map messageVariables(
+ @NotNull UUID accountId,
+ @NotNull String damSessionId,
+ @NotNull DDRemoteDamMessage message
+ ) {
+ if (damSessionId.isBlank()) {
+ throw new IllegalArgumentException("A session ID is required");
+ }
+ return Map.of(
+ "accountId", Objects.requireNonNull(accountId).toString(), "damSessionId", damSessionId,
+ "message", Objects.requireNonNull(message)
+ );
+ }
+
+ @NotNull
+ private static Map subscriptionVariables(
+ @NotNull String subscriberId,
+ @NotNull UUID subscriptionId,
+ @NotNull UUID accountId,
+ @Nullable String damSessionId,
+ @NotNull String topic,
+ @NotNull String callbackUrl,
+ @NotNull String callbackSecret
+ ) {
+ requireSubscriberId(subscriberId);
+ requireText(topic);
+ if (damSessionId != null) {
+ requireText(damSessionId);
+ }
+ if (!DDRemoteDamApi.isValidServiceUrl(callbackUrl)) {
+ throw new IllegalArgumentException("Invalid Remote DAM callback URL");
+ }
+ if (!DDRemoteDamApi.isValidServiceSecret(callbackSecret)) {
+ throw new IllegalArgumentException("Invalid Remote DAM callback secret");
+ }
+ Map input = new HashMap<>();
+ input.put("subscriberId", subscriberId);
+ input.put("subscriptionId", Objects.requireNonNull(subscriptionId).toString());
+ input.put("accountId", Objects.requireNonNull(accountId).toString());
+ input.put("damSessionId", damSessionId);
+ input.put("topic", topic);
+ input.put("callbackUrl", callbackUrl);
+ input.put("callbackSecret", callbackSecret);
+ return Map.of("input", input);
+ }
+
+ @NotNull
+ private static Map unsubscriptionVariables(@NotNull String subscriberId, @NotNull UUID subscriptionId) {
+ requireSubscriberId(subscriberId);
+ return Map.of("subscriberId", subscriberId, "subscriptionId", Objects.requireNonNull(subscriptionId).toString());
+ }
+
+ @NotNull
+ private static String readString(@NotNull JsonObject object, @NotNull String field) throws DDRemoteDamException {
+ JsonElement value = object.get(field);
+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString() || value.getAsString().isBlank()) {
+ throw invalidResponse();
+ }
+ return value.getAsString();
+ }
+
+ private static void requireSubscriberId(@Nullable String subscriberId) {
+ if (!DDRemoteDamApi.isValidSubscriberId(subscriberId)) {
+ throw new IllegalArgumentException("Invalid subscription metadata");
+ }
+ }
+
+ private static void requireText(@NotNull String value) {
+ if (value.isBlank() || value.length() > 256) {
+ throw new IllegalArgumentException("Invalid subscription metadata");
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java
new file mode 100644
index 0000000..e35a424
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamGraphQlClient.java
@@ -0,0 +1,243 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.client;
+
+import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi;
+import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException;
+import com.dbeaver.rest.client.AbstractRestClient;
+import com.dbeaver.rest.client.MediaType;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+import org.jkiss.dbeaver.DBException;
+import org.jkiss.utils.CommonUtils;
+import org.jkiss.utils.HttpConstants;
+
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.net.http.HttpTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+abstract class DDRemoteDamGraphQlClient extends AbstractRestClient {
+ private final String serviceSecret;
+ private final String endpoint;
+ private final Duration requestTimeout;
+
+ DDRemoteDamGraphQlClient(@NotNull String serviceUrl, @NotNull String serviceSecret, @NotNull String endpoint, int timeoutMillis) {
+ super(validateServiceUrl(serviceUrl), Math.min(DEFAULT_CONNECT_TIMEOUT, timeoutMillis), timeoutMillis,
+ List.of(), HttpClient.Redirect.NEVER);
+ if (!DDRemoteDamApi.isValidServiceSecret(serviceSecret)) {
+ throw new IllegalArgumentException("A valid Remote DAM service secret is required");
+ }
+ this.serviceSecret = serviceSecret;
+ this.endpoint = endpoint;
+ this.requestTimeout = Duration.ofMillis(timeoutMillis > 0 ? timeoutMillis : DEFAULT_READ_TIMEOUT);
+ }
+
+ @NotNull
+ final JsonElement request(
+ @NotNull String operationName,
+ @NotNull String query,
+ @NotNull Map variables,
+ @NotNull String field
+ ) throws DDRemoteDamException {
+ String response;
+ try {
+ response = execute(createRequest(operationName, query, variables), String.class);
+ } catch (DBException e) {
+ throw requestFailure(e);
+ }
+ return readResult(response, field);
+ }
+
+ @NotNull
+ final CompletionStage requestAsync(
+ @NotNull String operationName,
+ @NotNull String query,
+ @NotNull Map variables,
+ @NotNull String field
+ ) {
+ try {
+ HttpRequest request = createRequest(operationName, query, variables).build();
+ var exchange = getHttpClient().sendAsync(request, BodyHandlers.ofString(StandardCharsets.UTF_8));
+ var bounded = exchange.copy().orTimeout(requestTimeout.toMillis(), TimeUnit.MILLISECONDS);
+ var completed = bounded.whenComplete((response, error) -> {
+ if (error instanceof TimeoutException || bounded.isCancelled()) {
+ exchange.cancel(true);
+ }
+ });
+ return propagateCancellation(completed.handle((response, error) -> {
+ if (error != null) {
+ throw new CompletionException(requestFailure(error));
+ }
+ try {
+ int code = response.statusCode();
+ if (code < HttpConstants.CODE_OK || code >= HttpURLConnection.HTTP_MULT_CHOICE) {
+ throw mapErrorResponse(code, "", request.uri());
+ }
+ return readResult(response.body(), field);
+ } catch (DDRemoteDamException e) {
+ throw new CompletionException(e);
+ }
+ }), bounded);
+ } catch (DBException | RuntimeException e) {
+ return CompletableFuture.failedFuture(requestFailure(e));
+ }
+ }
+
+ @NotNull
+ final CompletionStage requestAsync(
+ @NotNull String operationName,
+ @NotNull String query,
+ @NotNull Map variables,
+ @NotNull String field,
+ @NotNull ResultReader reader
+ ) {
+ CompletableFuture response = requestAsync(operationName, query, variables, field).toCompletableFuture();
+ return propagateCancellation(response.thenApply(result -> {
+ try {
+ return reader.read(result);
+ } catch (DDRemoteDamException e) {
+ throw new CompletionException(e);
+ }
+ }), response);
+ }
+
+ @NotNull
+ private static CompletionStage propagateCancellation(
+ @NotNull CompletableFuture result,
+ @NotNull CompletableFuture> source
+ ) {
+ CompletableFuture completion = new CompletableFuture<>();
+ result.whenComplete((value, error) -> {
+ if (error == null) {
+ completion.complete(value);
+ } else {
+ completion.completeExceptionally(error);
+ }
+ });
+ completion.whenComplete((value, error) -> {
+ if (completion.isCancelled()) {
+ result.cancel(true);
+ source.cancel(true);
+ }
+ });
+ return completion;
+ }
+
+ @NotNull
+ private HttpRequest.Builder createRequest(
+ @NotNull String operationName,
+ @NotNull String query,
+ @NotNull Map variables
+ ) throws DBException {
+ return HttpRequest.newBuilder(buildUri(CommonUtils.removeLeadingSlash(endpoint), Map.of()))
+ .timeout(requestTimeout)
+ .header(DDRemoteDamApi.HEADER_SERVICE_TOKEN, serviceSecret)
+ .header(HttpConstants.HEADER_CONTENT_TYPE, MediaType.JSON.toString())
+ .header(HttpConstants.HEADER_ACCEPT, MediaType.JSON.toString())
+ .header(HttpConstants.HEADER_CACHE_CONTROL, HttpConstants.CACHE_CONTROL_NO_STORE)
+ .POST(createBodyPublisher(Map.of("operationName", operationName, "query", query, "variables", variables), MediaType.JSON));
+ }
+
+ @NotNull
+ private static JsonElement readResult(@NotNull String response, @NotNull String field) throws DDRemoteDamException {
+ JsonObject envelope;
+ try {
+ envelope = readObject(JsonParser.parseString(response));
+ } catch (JsonParseException e) {
+ throw invalidResponse();
+ }
+ JsonElement errors = envelope.get("errors");
+ if (errors != null && (!errors.isJsonArray() || !errors.getAsJsonArray().isEmpty())) {
+ throw new DDRemoteDamException("Remote DAM GraphQL request failed");
+ }
+ JsonElement result = readObject(envelope.get("data")).get(field);
+ if (result == null || result.isJsonNull()) {
+ throw invalidResponse();
+ }
+ return result;
+ }
+
+ @Override
+ @NotNull
+ protected DDRemoteDamException mapErrorResponse(int code, @NotNull String message, @NotNull URI uri) {
+ return new DDRemoteDamException("Remote DAM request failed: HTTP " + code);
+ }
+
+ static boolean readBoolean(@NotNull JsonElement value) throws DDRemoteDamException {
+ if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) {
+ throw invalidResponse();
+ }
+ return value.getAsBoolean();
+ }
+
+ @NotNull
+ static JsonObject readObject(@Nullable JsonElement value) throws DDRemoteDamException {
+ if (value == null || !value.isJsonObject()) {
+ throw invalidResponse();
+ }
+ return value.getAsJsonObject();
+ }
+
+ @NotNull
+ static DDRemoteDamException invalidResponse() {
+ return new DDRemoteDamException("Invalid Remote DAM response");
+ }
+
+ @NotNull
+ private static DDRemoteDamException requestFailure(@NotNull Throwable error) {
+ while (error instanceof CompletionException && error.getCause() != null) {
+ error = error.getCause();
+ }
+ if (error instanceof TimeoutException) {
+ HttpTimeoutException timeout = new HttpTimeoutException("Remote DAM service request timed out");
+ timeout.initCause(error);
+ error = timeout;
+ }
+ return error instanceof DDRemoteDamException failure
+ ? failure : new DDRemoteDamException("Remote DAM service request failed", error);
+ }
+
+ @NotNull
+ private static String validateServiceUrl(@NotNull String serviceUrl) {
+ if (!DDRemoteDamApi.isValidServiceUrl(serviceUrl)) {
+ throw new IllegalArgumentException("Invalid Remote DAM service URL");
+ }
+ return serviceUrl;
+ }
+
+ @FunctionalInterface
+ interface ResultReader {
+ @Nullable
+ T read(@NotNull JsonElement result) throws DDRemoteDamException;
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java
new file mode 100644
index 0000000..25fa401
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/exception/DDRemoteDamException.java
@@ -0,0 +1,30 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.exception;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.dbeaver.DBException;
+
+public class DDRemoteDamException extends DBException {
+ public DDRemoteDamException(@NotNull String message) {
+ super(message);
+ }
+
+ public DDRemoteDamException(@NotNull String message, @NotNull Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java
new file mode 100644
index 0000000..ff22925
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeRequest.java
@@ -0,0 +1,33 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.model;
+
+import org.jkiss.code.NotNull;
+
+public record DDRemoteDamHandshakeRequest(
+ @NotNull String appDeploymentId,
+ @NotNull String appInstanceId,
+ @NotNull String product,
+ @NotNull String version
+) {
+ public DDRemoteDamHandshakeRequest {
+ DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId");
+ DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId");
+ DDRemoteDamMessage.requireText(product, "product");
+ DDRemoteDamMessage.requireText(version, "version");
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java
new file mode 100644
index 0000000..2d38996
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamHandshakeResponse.java
@@ -0,0 +1,25 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.model;
+
+import org.jkiss.code.NotNull;
+
+public record DDRemoteDamHandshakeResponse(@NotNull String damSessionId) {
+ public DDRemoteDamHandshakeResponse {
+ DDRemoteDamMessage.requireText(damSessionId, "damSessionId");
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java
new file mode 100644
index 0000000..4eaa3cf
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessage.java
@@ -0,0 +1,47 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.model;
+
+import com.google.gson.JsonObject;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.Objects;
+
+public record DDRemoteDamMessage(
+ @NotNull String topic,
+ @NotNull String type,
+ @NotNull JsonObject payload
+) {
+ public DDRemoteDamMessage {
+ requireText(topic, "topic");
+ requireText(type, "type");
+ payload = Objects.requireNonNull(payload, "payload").deepCopy();
+ }
+
+ @Override
+ @NotNull
+ public JsonObject payload() {
+ return payload.deepCopy();
+ }
+
+ static void requireText(@Nullable String value, @NotNull String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException("Missing " + name);
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java
new file mode 100644
index 0000000..3735860
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSession.java
@@ -0,0 +1,35 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.model;
+
+import org.jkiss.code.NotNull;
+
+public record DDRemoteDamSession(
+ @NotNull String appDeploymentId,
+ @NotNull String appInstanceId,
+ @NotNull String damSessionId,
+ @NotNull String product,
+ @NotNull String version
+) {
+ public DDRemoteDamSession {
+ DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId");
+ DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId");
+ DDRemoteDamMessage.requireText(damSessionId, "damSessionId");
+ DDRemoteDamMessage.requireText(product, "product");
+ DDRemoteDamMessage.requireText(version, "version");
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java
new file mode 100644
index 0000000..c712772
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionInfo.java
@@ -0,0 +1,31 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.model;
+
+import org.jkiss.code.NotNull;
+
+public record DDRemoteDamSessionInfo(
+ @NotNull String appDeploymentId,
+ @NotNull String appInstanceId,
+ @NotNull String damSessionId
+) {
+ public DDRemoteDamSessionInfo {
+ DDRemoteDamMessage.requireText(appDeploymentId, "appDeploymentId");
+ DDRemoteDamMessage.requireText(appInstanceId, "appInstanceId");
+ DDRemoteDamMessage.requireText(damSessionId, "damSessionId");
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java
new file mode 100644
index 0000000..ec55475
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransport.java
@@ -0,0 +1,448 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.requestreply;
+
+import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyException;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport;
+import com.dbeaver.messaging.requestreply.MessagingSubscription;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Semaphore;
+
+public final class DDRemoteDamRequestReplyTransport implements MessagingRequestReplyTransport, AutoCloseable {
+ private static final String MESSAGE_TYPE = "datadam.requestreply.v1";
+ private static final String FIELD_REQUEST_ID = "requestId";
+ private static final String FIELD_REPLY_TO = "replyTo";
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_ERROR = "error";
+ private static final String FIELD_DATA = "data";
+ private static final int MAX_PENDING_SENDS = 128;
+ private static final int MAX_PENDING_DELIVERIES = 128;
+ private final Object lock = new Object();
+ private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS);
+ private final String topic;
+ private final String replyPrefix;
+ private final Sender sender;
+ private final TopicSubscriber subscriber;
+ @Nullable
+ private final DDRemoteDamChannel channel;
+ @Nullable
+ private Registration registration;
+ private boolean claimed;
+
+ /** Sends messages asynchronously to a selected peer. */
+ @FunctionalInterface
+ public interface Sender {
+ @NotNull
+ CompletionStage send(@NotNull DDRemoteDamMessage message);
+ }
+
+ /** Registers topic listeners for a selected peer. */
+ @FunctionalInterface
+ public interface TopicSubscriber {
+ @NotNull
+ DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) throws DDRemoteDamException;
+ }
+
+ public DDRemoteDamRequestReplyTransport(@NotNull String topic, @NotNull Sender sender, @NotNull TopicSubscriber subscriber) {
+ this(topic, sender, subscriber, null);
+ }
+
+ private DDRemoteDamRequestReplyTransport(
+ @NotNull String topic,
+ @NotNull Sender sender,
+ @NotNull TopicSubscriber subscriber,
+ @Nullable DDRemoteDamChannel channel
+ ) {
+ if (topic.isBlank() || topic.length() > 256) {
+ throw new IllegalArgumentException("Expected a nonblank request/reply topic of at most 256 characters");
+ }
+ this.topic = topic;
+ this.replyPrefix = replyPrefix(topic);
+ this.sender = Objects.requireNonNull(sender);
+ this.subscriber = Objects.requireNonNull(subscriber);
+ this.channel = channel;
+ }
+
+ @NotNull
+ public static DDRemoteDamRequestReplyTransport forChannel(@NotNull DDRemoteDamChannel channel, @NotNull String topic) {
+ Objects.requireNonNull(channel);
+ return new DDRemoteDamRequestReplyTransport(topic, channel::send, channel::subscribe, channel);
+ }
+
+ @Override
+ @NotNull
+ public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener listener)
+ throws MessagingRequestReplyException {
+ Registration opened;
+ synchronized (lock) {
+ if (claimed) {
+ throw new IllegalStateException("A request/reply adapter supports only one endpoint and cannot be reopened");
+ }
+ claimed = true;
+ opened = new Registration(Objects.requireNonNull(endpointId), Objects.requireNonNull(listener));
+ registration = opened;
+ }
+ try {
+ if (channel != null) {
+ opened.state(channel.getStatus());
+ opened.requireReady();
+ opened.add(channel.observeState(opened::state));
+ }
+ opened.add(subscriber.subscribe(opened.replyTopic, (session, message) -> opened.receive(session, message, false)));
+ opened.add(subscriber.subscribe(topic, (session, message) -> opened.receive(session, message, true)));
+ if (channel != null) {
+ opened.state(channel.getStatus());
+ }
+ synchronized (lock) {
+ opened.requireReady();
+ opened.opening = false;
+ }
+ return opened;
+ } catch (DDRemoteDamException | MessagingRequestReplyException | RuntimeException e) {
+ try {
+ opened.close();
+ } catch (RuntimeException closeFailure) {
+ e.addSuppressed(closeFailure);
+ }
+ if (e instanceof MessagingRequestReplyException failure) {
+ throw failure;
+ }
+ throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e);
+ }
+ }
+
+ @Override
+ @NotNull
+ public CompletionStage send(@NotNull MessagingRequestReplyMessage message) {
+ synchronized (lock) {
+ if (registration == null || registration.closed) {
+ return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED));
+ }
+ if (channel != null && !channel.getStatus().isReady()) {
+ return CompletableFuture.failedFuture(
+ new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED));
+ }
+ if (message.type() == MessagingRequestReplyMessage.Type.REQUEST && !registration.endpointId.equals(message.replyTo())) {
+ return CompletableFuture.failedFuture(new IllegalArgumentException("Request origin does not match its endpoint"));
+ }
+ }
+ if (!sendBudget.tryAcquire()) {
+ return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED));
+ }
+ try {
+ JsonObject envelope = new JsonObject();
+ envelope.addProperty(FIELD_REQUEST_ID, message.requestId().toString());
+ envelope.addProperty(FIELD_REPLY_TO, message.replyTo().toString());
+ envelope.addProperty(FIELD_TYPE, message.type().name());
+ if (message.type() == MessagingRequestReplyMessage.Type.ERROR) {
+ envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name());
+ } else {
+ envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload());
+ }
+ String target = message.type() == MessagingRequestReplyMessage.Type.REQUEST ? topic : replyPrefix + message.replyTo();
+ CompletableFuture result = new CompletableFuture<>();
+ Objects.requireNonNull(sender.send(new DDRemoteDamMessage(target, MESSAGE_TYPE, envelope))).whenComplete((ignored, error) -> {
+ sendBudget.release();
+ if (error == null) {
+ result.complete(null);
+ } else {
+ result.completeExceptionally(error);
+ }
+ });
+ return result;
+ } catch (RuntimeException | Error e) {
+ sendBudget.release();
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @Override
+ public void close() {
+ Registration stopped;
+ synchronized (lock) {
+ claimed = true;
+ stopped = registration;
+ }
+ if (stopped != null) {
+ stopped.close();
+ }
+ }
+
+ @NotNull
+ private static String replyPrefix(@NotNull String topic) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(topic.getBytes(StandardCharsets.UTF_8));
+ return "datadam.reply.v1." + HexFormat.of().formatHex(digest) + ".";
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Nullable
+ private static MessagingRequestReplyMessage decode(@NotNull DDRemoteDamMessage message) {
+ if (!MESSAGE_TYPE.equals(message.type())) {
+ return null;
+ }
+ try {
+ JsonObject envelope = message.payload();
+ UUID requestId = uuid(envelope, FIELD_REQUEST_ID);
+ UUID replyTo = uuid(envelope, FIELD_REPLY_TO);
+ MessagingRequestReplyMessage.Type type = MessagingRequestReplyMessage.Type.valueOf(string(envelope, FIELD_TYPE));
+ JsonElement data = envelope.get(FIELD_DATA);
+ MessagingRequestReplyMessage.Error error = null;
+ JsonObject payload = null;
+ if (type == MessagingRequestReplyMessage.Type.ERROR) {
+ if (data != null && !data.isJsonNull()) {
+ return null;
+ }
+ error = MessagingRequestReplyMessage.Error.valueOf(string(envelope, FIELD_ERROR));
+ } else {
+ if (envelope.has(FIELD_ERROR) || data == null || !data.isJsonObject() && !data.isJsonNull()
+ || type == MessagingRequestReplyMessage.Type.REQUEST && data.isJsonNull()) {
+ return null;
+ }
+ payload = data.isJsonNull() ? null : data.getAsJsonObject();
+ }
+ return new MessagingRequestReplyMessage<>(requestId, replyTo, type, payload, error);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ @NotNull
+ private static String string(@NotNull JsonObject envelope, @NotNull String field) {
+ JsonElement value = envelope.get(field);
+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
+ throw new IllegalArgumentException("Invalid request/reply field");
+ }
+ return value.getAsString();
+ }
+
+ @NotNull
+ private static UUID uuid(@NotNull JsonObject envelope, @NotNull String field) {
+ String value = string(envelope, field);
+ UUID id = UUID.fromString(value);
+ if (value.length() != 36 || !id.toString().equalsIgnoreCase(value)) {
+ throw new IllegalArgumentException("Invalid request/reply UUID");
+ }
+ return id;
+ }
+
+ private final class Registration implements MessagingSubscription {
+ private final UUID endpointId;
+ private final String replyTopic;
+ private final Listener listener;
+ private final List subscriptions = new ArrayList<>();
+ private final ArrayDeque deliveries = new ArrayDeque<>();
+ @Nullable
+ private DDRemoteDamChannelStatus status;
+ private boolean opening = true;
+ private boolean openingDisconnected;
+ private boolean delivering;
+ private boolean closed;
+
+ private Registration(@NotNull UUID endpointId, @NotNull Listener listener) {
+ this.endpointId = endpointId;
+ this.replyTopic = replyPrefix + endpointId;
+ this.listener = listener;
+ }
+
+ private void add(@NotNull DDRemoteDamSubscription subscription) {
+ synchronized (lock) {
+ if (!closed) {
+ subscriptions.add(Objects.requireNonNull(subscription));
+ return;
+ }
+ }
+ subscription.close();
+ }
+
+ private void requireReady() throws MessagingRequestReplyException {
+ synchronized (lock) {
+ if (closed) {
+ throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED);
+ }
+ if (openingDisconnected || channel != null && (status == null || !status.isReady())) {
+ throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.DISCONNECTED);
+ }
+ }
+ }
+
+ private void state(@NotNull DDRemoteDamChannelStatus next) {
+ boolean drain;
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ openingDisconnected |= opening && !next.isReady();
+ drain = updateState(channel == null ? next : channel.getStatus());
+ }
+ if (drain) {
+ drain();
+ }
+ }
+
+ private boolean updateState(@NotNull DDRemoteDamChannelStatus next) {
+ if (next.equals(status)) {
+ return false;
+ }
+ boolean disconnected = !next.isReady() || status != null && status.isReady()
+ && !Objects.equals(status.session(), next.session());
+ status = next;
+ openingDisconnected |= opening && disconnected;
+ return disconnected && enqueue(null);
+ }
+
+ private void receive(@NotNull DDRemoteDamSessionInfo session, @NotNull DDRemoteDamMessage message, boolean requestTopic) {
+ MessagingRequestReplyMessage frame = decode(message);
+ if (frame == null || (requestTopic ? frame.type() != MessagingRequestReplyMessage.Type.REQUEST
+ : frame.type() == MessagingRequestReplyMessage.Type.REQUEST || !endpointId.equals(frame.replyTo()))) {
+ return;
+ }
+ boolean drain = false;
+ synchronized (lock) {
+ if (closed || !(requestTopic ? topic : replyTopic).equals(message.topic())) {
+ return;
+ }
+ if (channel != null) {
+ DDRemoteDamChannelStatus current = channel.getStatus();
+ if (!current.isReady() || !session.equals(current.session())) {
+ return;
+ }
+ drain = updateState(current);
+ drain |= enqueue(frame);
+ }
+ }
+ if (channel == null) {
+ try {
+ listener.onMessage(frame);
+ } catch (RuntimeException | Error e) {
+ try {
+ close();
+ } catch (RuntimeException closeFailure) {
+ e.addSuppressed(closeFailure);
+ }
+ throw e;
+ }
+ } else if (drain) {
+ drain();
+ }
+ }
+
+ private boolean enqueue(@Nullable MessagingRequestReplyMessage message) {
+ if (message == null) {
+ if (!deliveries.isEmpty() && deliveries.getLast().message() == null) {
+ return false;
+ }
+ if (deliveries.size() >= MAX_PENDING_DELIVERIES) {
+ deliveries.clear();
+ }
+ } else if (deliveries.size() >= MAX_PENDING_DELIVERIES) {
+ return false;
+ }
+ deliveries.add(new Delivery(message));
+ if (delivering) {
+ return false;
+ }
+ delivering = true;
+ return true;
+ }
+
+ private void drain() {
+ try {
+ while (true) {
+ Delivery delivery;
+ synchronized (lock) {
+ delivery = deliveries.poll();
+ if (closed || delivery == null) {
+ delivering = false;
+ return;
+ }
+ }
+ if (delivery.message() == null) {
+ listener.onDisconnect();
+ } else {
+ listener.onMessage(delivery.message());
+ }
+ }
+ } catch (RuntimeException | Error e) {
+ try {
+ close();
+ } catch (RuntimeException closeFailure) {
+ e.addSuppressed(closeFailure);
+ }
+ throw e;
+ }
+ }
+
+ @Override
+ public void close() {
+ List stopped;
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ deliveries.clear();
+ stopped = List.copyOf(subscriptions);
+ subscriptions.clear();
+ }
+ RuntimeException failure = null;
+ for (DDRemoteDamSubscription subscription : stopped.reversed()) {
+ try {
+ subscription.close();
+ } catch (RuntimeException e) {
+ if (failure == null) {
+ failure = e;
+ } else {
+ failure.addSuppressed(e);
+ }
+ }
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ }
+ }
+
+ private record Delivery(@Nullable MessagingRequestReplyMessage message) {
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java
new file mode 100644
index 0000000..2c5cd95
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodec.java
@@ -0,0 +1,47 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.rpc;
+
+import com.dbeaver.messaging.rpc.MessagingRpcCodec;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.lang.reflect.Type;
+import java.util.Objects;
+
+public final class DDRemoteDamGsonRpcCodec implements MessagingRpcCodec {
+ private final Gson gson;
+
+ public DDRemoteDamGsonRpcCodec(@NotNull Gson gson) {
+ this.gson = Objects.requireNonNull(gson);
+ }
+
+ @Override
+ @Nullable
+ public JsonElement encode(@Nullable Object value, @NotNull Type type) {
+ JsonElement encoded = gson.toJsonTree(value, Objects.requireNonNull(type));
+ return encoded == null || encoded.isJsonNull() ? null : encoded;
+ }
+
+ @Override
+ @Nullable
+ public Object decode(@Nullable JsonElement payload, @NotNull Type type) {
+ return gson.fromJson(payload, Objects.requireNonNull(type));
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java
new file mode 100644
index 0000000..a82c0ed
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransport.java
@@ -0,0 +1,266 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.rpc;
+
+import com.dbeaver.datadam.dam.remote.api.requestreply.DDRemoteDamRequestReplyTransport;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyException;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport;
+import com.dbeaver.messaging.requestreply.MessagingSubscription;
+import com.dbeaver.messaging.rpc.MessagingRpcExceptionDetails;
+import com.dbeaver.messaging.rpc.MessagingRpcMessage;
+import com.dbeaver.messaging.rpc.MessagingRpcTransport;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Semaphore;
+
+public final class DDRemoteDamRpcTransport implements MessagingRequestReplyTransport> {
+ private static final String PROTOCOL = "dbeaver.rpc.v2";
+ private static final String FIELD_PROTOCOL = "protocol";
+ private static final String FIELD_TYPE = "type";
+ private static final String FIELD_ERROR = "error";
+ private static final String FIELD_OPERATION = "operation";
+ private static final String FIELD_DATA = "data";
+ private static final String FIELD_EXCEPTION = "exception";
+ private static final String FIELD_MESSAGE = "message";
+ private static final String FIELD_STACK_TRACE = "stackTrace";
+ private static final String FIELD_TRUNCATED = "truncated";
+ private static final int MAX_PENDING_SENDS = 128;
+ private final Semaphore sendBudget = new Semaphore(MAX_PENDING_SENDS);
+ private final MessagingRequestReplyTransport delegate;
+ @Nullable
+ private final Executor requestExecutor;
+
+ public DDRemoteDamRpcTransport(@NotNull MessagingRequestReplyTransport delegate) {
+ this(delegate, null);
+ }
+
+ public DDRemoteDamRpcTransport(@NotNull MessagingRequestReplyTransport delegate, @Nullable Executor requestExecutor) {
+ this.delegate = Objects.requireNonNull(delegate);
+ this.requestExecutor = requestExecutor;
+ }
+
+ @NotNull
+ public static MessagingRpcTransport forChannel(@NotNull DDRemoteDamChannel channel) {
+ Objects.requireNonNull(channel);
+ return topic -> forChannel(channel, topic);
+ }
+
+ @NotNull
+ public static DDRemoteDamRpcTransport forChannel(@NotNull DDRemoteDamChannel channel, @NotNull String topic) {
+ return new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(channel, topic));
+ }
+
+ @NotNull
+ public static DDRemoteDamRpcTransport forChannel(
+ @NotNull DDRemoteDamChannel channel,
+ @NotNull String topic,
+ @NotNull Executor requestExecutor
+ ) {
+ return new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(channel, topic),
+ Objects.requireNonNull(requestExecutor));
+ }
+
+ @Override
+ public void executeRequest(@NotNull Runnable request) {
+ if (requestExecutor == null) {
+ delegate.executeRequest(request);
+ } else {
+ requestExecutor.execute(request);
+ }
+ }
+
+ @Override
+ @NotNull
+ public MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener> listener)
+ throws MessagingRequestReplyException {
+ Objects.requireNonNull(endpointId);
+ Objects.requireNonNull(listener);
+ return delegate.subscribe(endpointId, new Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ MessagingRequestReplyMessage> decoded = decode(message);
+ if (decoded != null) {
+ listener.onMessage(decoded);
+ }
+ }
+
+ @Override
+ public void onDisconnect() {
+ listener.onDisconnect();
+ }
+ });
+ }
+
+ @Override
+ @NotNull
+ public CompletionStage send(@NotNull MessagingRequestReplyMessage> message) {
+ if (!sendBudget.tryAcquire()) {
+ return CompletableFuture.failedFuture(new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED));
+ }
+ try {
+ MessagingRpcMessage payload = message.payload();
+ if (payload != null) {
+ requireType(message.type(), payload.type());
+ }
+ MessagingRequestReplyMessage frame = new MessagingRequestReplyMessage<>(
+ message.requestId(), message.replyTo(), message.type(),
+ payload == null ? null : encode(payload), message.error());
+ CompletableFuture result = new CompletableFuture<>();
+ Objects.requireNonNull(delegate.send(frame)).whenComplete((ignored, error) -> {
+ sendBudget.release();
+ if (error == null) {
+ result.complete(null);
+ } else {
+ result.completeExceptionally(error);
+ }
+ });
+ return result;
+ } catch (RuntimeException | Error e) {
+ sendBudget.release();
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ @NotNull
+ private static JsonObject encode(@NotNull MessagingRpcMessage message) {
+ JsonObject envelope = new JsonObject();
+ envelope.addProperty(FIELD_PROTOCOL, PROTOCOL);
+ envelope.addProperty(FIELD_TYPE, message.type().name());
+ if (message.type() == MessagingRpcMessage.Type.ERROR) {
+ envelope.addProperty(FIELD_ERROR, Objects.requireNonNull(message.error()).name());
+ envelope.add(FIELD_EXCEPTION, message.exception() == null ? JsonNull.INSTANCE : encodeException(message.exception()));
+ } else {
+ if (message.type() == MessagingRpcMessage.Type.REQUEST) {
+ envelope.addProperty(FIELD_OPERATION, message.operation());
+ JsonArray arguments = new JsonArray();
+ for (JsonElement argument : message.arguments()) {
+ arguments.add(argument == null ? JsonNull.INSTANCE : argument.deepCopy());
+ }
+ envelope.add(FIELD_DATA, arguments);
+ } else {
+ envelope.add(FIELD_DATA, message.payload() == null ? JsonNull.INSTANCE : message.payload().deepCopy());
+ }
+ }
+ return envelope;
+ }
+
+ @NotNull
+ private static JsonObject encodeException(@NotNull MessagingRpcExceptionDetails details) {
+ JsonObject exception = new JsonObject();
+ exception.addProperty(FIELD_TYPE, details.type());
+ exception.addProperty(FIELD_MESSAGE, details.message());
+ exception.addProperty(FIELD_STACK_TRACE, details.stackTrace());
+ exception.addProperty(FIELD_TRUNCATED, details.truncated());
+ return exception;
+ }
+
+ @Nullable
+ private static MessagingRequestReplyMessage> decode(
+ @NotNull MessagingRequestReplyMessage message
+ ) {
+ if (message.type() == MessagingRequestReplyMessage.Type.ERROR || message.payload() == null) {
+ return new MessagingRequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), null, message.error());
+ }
+ try {
+ JsonObject envelope = message.payload();
+ if (!PROTOCOL.equals(string(envelope, FIELD_PROTOCOL))) {
+ return null;
+ }
+ MessagingRpcMessage.Type type = MessagingRpcMessage.Type.valueOf(string(envelope, FIELD_TYPE));
+ requireType(message.type(), type);
+ MessagingRpcMessage payload;
+ if (type == MessagingRpcMessage.Type.ERROR) {
+ JsonElement exception = envelope.get(FIELD_EXCEPTION);
+ if (envelope.size() != 4 || exception == null) {
+ return null;
+ }
+ payload = MessagingRpcMessage.failure(MessagingRpcMessage.Error.valueOf(string(envelope, FIELD_ERROR)),
+ decodeException(exception));
+ } else {
+ JsonElement data = envelope.get(FIELD_DATA);
+ if (data == null || envelope.size() != (type == MessagingRpcMessage.Type.REQUEST ? 4 : 3)) {
+ return null;
+ }
+ if (type == MessagingRpcMessage.Type.REQUEST) {
+ if (!data.isJsonArray()) {
+ return null;
+ }
+ List arguments = new ArrayList<>(data.getAsJsonArray().size());
+ for (JsonElement argument : data.getAsJsonArray()) {
+ arguments.add(argument.isJsonNull() ? null : argument.deepCopy());
+ }
+ payload = MessagingRpcMessage.request(string(envelope, FIELD_OPERATION), arguments);
+ } else {
+ payload = MessagingRpcMessage.response(data.isJsonNull() ? null : data.deepCopy());
+ }
+ }
+ return new MessagingRequestReplyMessage<>(message.requestId(), message.replyTo(), message.type(), payload, message.error());
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ @Nullable
+ private static MessagingRpcExceptionDetails decodeException(@NotNull JsonElement value) {
+ if (value.isJsonNull()) {
+ return null;
+ }
+ if (!value.isJsonObject()) {
+ throw new IllegalArgumentException("Expected RPC exception details");
+ }
+ JsonObject exception = value.getAsJsonObject();
+ JsonElement message = exception.get(FIELD_MESSAGE);
+ JsonElement truncated = exception.get(FIELD_TRUNCATED);
+ if (exception.size() != 4 || message == null || truncated == null || !truncated.isJsonPrimitive()
+ || !truncated.getAsJsonPrimitive().isBoolean()) {
+ throw new IllegalArgumentException("Invalid RPC exception details");
+ }
+ return new MessagingRpcExceptionDetails(string(exception, FIELD_TYPE),
+ message.isJsonNull() ? null : string(exception, FIELD_MESSAGE), string(exception, FIELD_STACK_TRACE),
+ truncated.getAsBoolean());
+ }
+
+ private static void requireType(@NotNull MessagingRequestReplyMessage.Type outer, @NotNull MessagingRpcMessage.Type inner) {
+ if (outer == MessagingRequestReplyMessage.Type.REQUEST ? inner != MessagingRpcMessage.Type.REQUEST
+ : outer != MessagingRequestReplyMessage.Type.RESPONSE || inner == MessagingRpcMessage.Type.REQUEST) {
+ throw new IllegalArgumentException("RPC message type does not match its request/reply frame");
+ }
+ }
+
+ @NotNull
+ private static String string(@NotNull JsonObject envelope, @NotNull String field) {
+ JsonElement value = envelope.get(field);
+ if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
+ throw new IllegalArgumentException("Invalid RPC field");
+ }
+ return value.getAsString();
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java
new file mode 100644
index 0000000..939a3d9
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannel.java
@@ -0,0 +1,45 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.transport;
+
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import org.jkiss.code.NotNull;
+
+import java.util.concurrent.CompletionStage;
+import java.util.function.Consumer;
+
+/** Provides asynchronous DataDam messaging and session-state observation. */
+public interface DDRemoteDamChannel extends AutoCloseable {
+
+ @NotNull
+ DDRemoteDamChannelStatus getStatus();
+
+ @NotNull
+ DDRemoteDamSubscription observeState(@NotNull Consumer observer);
+
+ @NotNull
+ CompletionStage send(@NotNull DDRemoteDamMessage message);
+
+ @NotNull
+ DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener);
+
+ @NotNull
+ DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener);
+
+ @Override
+ void close();
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java
new file mode 100644
index 0000000..abfc0b2
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelState.java
@@ -0,0 +1,24 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.transport;
+
+public enum DDRemoteDamChannelState {
+ DISCONNECTED,
+ CONNECTING,
+ CONNECTED,
+ CLOSED
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java
new file mode 100644
index 0000000..4622d25
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamChannelStatus.java
@@ -0,0 +1,36 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.transport;
+
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.Objects;
+
+public record DDRemoteDamChannelStatus(@NotNull DDRemoteDamChannelState state, @Nullable DDRemoteDamSessionInfo session) {
+ public DDRemoteDamChannelStatus {
+ Objects.requireNonNull(state);
+ if ((state == DDRemoteDamChannelState.CONNECTED) != (session != null)) {
+ throw new IllegalArgumentException("Only a connected channel has session information");
+ }
+ }
+
+ public boolean isReady() {
+ return state == DDRemoteDamChannelState.CONNECTED;
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java
new file mode 100644
index 0000000..970fd7f
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamMessageListener.java
@@ -0,0 +1,27 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.transport;
+
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import org.jkiss.code.NotNull;
+
+/** Receives topic messages with their source session. */
+@FunctionalInterface
+public interface DDRemoteDamMessageListener {
+ void onMessage(@NotNull DDRemoteDamSessionInfo session, @NotNull DDRemoteDamMessage message);
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java
new file mode 100644
index 0000000..7d289fb
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSessionListener.java
@@ -0,0 +1,27 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.transport;
+
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import org.jkiss.code.NotNull;
+
+/** Receives physical session lifecycle notifications. */
+public interface DDRemoteDamSessionListener {
+ void onConnected(@NotNull DDRemoteDamSessionInfo session);
+
+ void onDisconnected(@NotNull DDRemoteDamSessionInfo session);
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java
new file mode 100644
index 0000000..1d8d261
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/datadam/dam/remote/api/transport/DDRemoteDamSubscription.java
@@ -0,0 +1,24 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.transport;
+
+/** Controls the lifetime of a DataDam listener registration. */
+@FunctionalInterface
+public interface DDRemoteDamSubscription extends AutoCloseable {
+ @Override
+ void close();
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java
new file mode 100644
index 0000000..e69099e
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestHandler.java
@@ -0,0 +1,27 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.requestreply;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+/** Handles incoming requests and returns response payloads. */
+@FunctionalInterface
+public interface MessagingRequestHandler {
+ @Nullable
+ P handle(@NotNull P request) throws Exception;
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java
new file mode 100644
index 0000000..b25fe65
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyEndpoint.java
@@ -0,0 +1,374 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.requestreply;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.LongSupplier;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+public final class MessagingRequestReplyEndpoint
implements AutoCloseable {
+ private static final Logger log = Logger.getLogger(MessagingRequestReplyEndpoint.class.getName());
+ private final Object lock = new Object();
+ private final UUID endpointId = UUID.randomUUID();
+ private final MessagingRequestReplyTransport
transport;
+ private final MessagingRequestReplyOptions options;
+ private final LongSupplier nanoTime;
+ private final Map pending = new HashMap<>();
+ private final Map incoming = new HashMap<>();
+ private final MessagingSubscription subscription;
+ @Nullable
+ private HandlerRegistration handler;
+ private boolean closed;
+ private long generation;
+
+ MessagingRequestReplyEndpoint(
+ @NotNull MessagingRequestReplyTransport transport,
+ @NotNull MessagingRequestReplyOptions options,
+ @NotNull LongSupplier nanoTime
+ ) throws MessagingRequestReplyException {
+ this.transport = Objects.requireNonNull(transport);
+ this.options = Objects.requireNonNull(options);
+ this.nanoTime = Objects.requireNonNull(nanoTime);
+ this.subscription = Objects.requireNonNull(transport.subscribe(endpointId, new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage
message) {
+ receive(message);
+ }
+
+ @Override
+ public void onDisconnect() {
+ disconnect();
+ }
+ }));
+ }
+
+ @NotNull
+ public static
MessagingRequestReplyEndpoint
open(
+ @NotNull MessagingRequestReplyTransport
transport
+ ) throws MessagingRequestReplyException {
+ return open(transport, MessagingRequestReplyOptions.DEFAULT);
+ }
+
+ @NotNull
+ public static
MessagingRequestReplyEndpoint
open(
+ @NotNull MessagingRequestReplyTransport
transport,
+ @NotNull MessagingRequestReplyOptions options
+ ) throws MessagingRequestReplyException {
+ return new MessagingRequestReplyEndpoint<>(transport, options, System::nanoTime);
+ }
+
+ @Nullable
+ public P request(@NotNull P payload) throws MessagingRequestReplyException, InterruptedException {
+ return request(payload, options.timeout());
+ }
+
+ @Nullable
+ public P request(@NotNull P payload, @NotNull Duration timeout) throws MessagingRequestReplyException, InterruptedException {
+ Objects.requireNonNull(payload);
+ long nanos = MessagingRequestReplyOptions.requireTimeout(timeout);
+ if (Thread.interrupted()) {
+ throw new InterruptedException();
+ }
+ long started = nanoTime.getAsLong();
+ UUID requestId = UUID.randomUUID();
+ Pending call = new Pending(started, nanos);
+ synchronized (lock) {
+ if (closed) {
+ throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.CLOSED);
+ }
+ if (pending.size() >= options.maxPendingRequests()) {
+ throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.OVERLOADED);
+ }
+ pending.put(requestId, call);
+ }
+ try {
+ if (call.remaining() <= 0) {
+ fail(requestId, call, new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT));
+ } else {
+ sendRequest(requestId, payload, call);
+ }
+ try {
+ return call.result.get(Math.max(0, call.remaining()), TimeUnit.NANOSECONDS);
+ } catch (TimeoutException e) {
+ fail(requestId, call, new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT));
+ return call.result.get();
+ }
+ } catch (ExecutionException e) {
+ if (e.getCause() instanceof MessagingRequestReplyException failure) {
+ throw failure;
+ }
+ throw new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, e.getCause());
+ } finally {
+ synchronized (lock) {
+ pending.remove(requestId, call);
+ }
+ }
+ }
+
+ @NotNull
+ public MessagingSubscription handle(@NotNull MessagingRequestHandler
requestHandler) {
+ Objects.requireNonNull(requestHandler);
+ synchronized (lock) {
+ if (closed || handler != null) {
+ throw new IllegalStateException("Request/reply handler registration is unavailable");
+ }
+ handler = new HandlerRegistration(requestHandler);
+ return handler;
+ }
+ }
+
+ private void sendRequest(@NotNull UUID requestId, @NotNull P payload, @NotNull Pending call) {
+ synchronized (lock) {
+ if (pending.get(requestId) != call) {
+ return;
+ }
+ }
+ try {
+ transport.send(MessagingRequestReplyMessage.request(requestId, endpointId, payload)).whenComplete((ignored, error) -> {
+ if (error != null) {
+ fail(requestId, call, sendFailure(error));
+ }
+ });
+ } catch (RuntimeException e) {
+ fail(requestId, call, sendFailure(e));
+ }
+ }
+
+ @NotNull
+ private static MessagingRequestReplyException sendFailure(@NotNull Throwable error) {
+ Throwable cause = error;
+ while ((cause instanceof CompletionException || cause instanceof ExecutionException) && cause.getCause() != null) {
+ cause = cause.getCause();
+ }
+ return cause instanceof MessagingRequestReplyException failure
+ ? failure : new MessagingRequestReplyException(MessagingRequestReplyException.Reason.SEND_FAILED, error);
+ }
+
+ private void fail(
+ @NotNull UUID requestId,
+ @NotNull Pending expected,
+ @NotNull MessagingRequestReplyException failure
+ ) {
+ synchronized (lock) {
+ if (pending.remove(requestId, expected)) {
+ expected.result.completeExceptionally(failure);
+ }
+ }
+ }
+
+ private void receive(@NotNull MessagingRequestReplyMessage
message) {
+ if (message.type() == MessagingRequestReplyMessage.Type.REQUEST) {
+ accept(message);
+ return;
+ }
+ synchronized (lock) {
+ if (!closed && endpointId.equals(message.replyTo())) {
+ Pending call = pending.remove(message.requestId());
+ if (call != null) {
+ if (call.remaining() <= 0) {
+ call.result.completeExceptionally(
+ new MessagingRequestReplyException(MessagingRequestReplyException.Reason.TIMEOUT));
+ } else if (message.type() == MessagingRequestReplyMessage.Type.RESPONSE) {
+ call.result.complete(message.payload());
+ } else {
+ call.result.completeExceptionally(new MessagingRequestReplyException(Objects.requireNonNull(message.error())));
+ }
+ }
+ }
+ }
+ }
+
+ private void accept(@NotNull MessagingRequestReplyMessage
message) {
+ Incoming accepted = null;
+ MessagingRequestReplyMessage.Error rejection = MessagingRequestReplyMessage.Error.OVERLOADED;
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ RequestKey key = new RequestKey(message.replyTo(), message.requestId());
+ if (incoming.containsKey(key)) {
+ return;
+ }
+ if (handler == null) {
+ rejection = MessagingRequestReplyMessage.Error.NO_HANDLER;
+ } else if (incoming.size() < options.maxConcurrentHandlers()) {
+ accepted = new Incoming(key, message, handler.requestHandler, generation);
+ incoming.put(key, accepted);
+ }
+ }
+ if (accepted != null) {
+ try {
+ transport.executeRequest(accepted);
+ return;
+ } catch (RejectedExecutionException e) {
+ accepted.task.cancel(false);
+ synchronized (lock) {
+ incoming.remove(accepted.key, accepted);
+ }
+ }
+ }
+ sendReply(message.failure(rejection));
+ }
+
+ private void sendReply(@NotNull MessagingRequestReplyMessage
message) {
+ try {
+ transport.send(message).whenComplete((ignored, error) -> {
+ if (error != null) {
+ log.log(Level.FINE, "Reply could not be sent");
+ }
+ });
+ } catch (RuntimeException e) {
+ log.log(Level.FINE, "Reply could not be sent");
+ }
+ }
+
+ private void disconnect() {
+ List stopped;
+ synchronized (lock) {
+ generation++;
+ failPending(MessagingRequestReplyException.Reason.DISCONNECTED);
+ stopped = List.copyOf(incoming.values());
+ }
+ stopped.forEach(request -> request.task.cancel(true));
+ }
+
+ private void failPending(@NotNull MessagingRequestReplyException.Reason reason) {
+ pending.values().forEach(call -> call.result.completeExceptionally(new MessagingRequestReplyException(reason)));
+ pending.clear();
+ }
+
+ @Override
+ public void close() {
+ List stopped;
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ handler = null;
+ failPending(MessagingRequestReplyException.Reason.CLOSED);
+ stopped = List.copyOf(incoming.values());
+ }
+ stopped.forEach(request -> request.task.cancel(true));
+ subscription.close();
+ }
+
+ private record RequestKey(@NotNull UUID replyTo, @NotNull UUID requestId) {
+ }
+
+ private final class Pending {
+ private final long started;
+ private final long nanos;
+ private final CompletableFuture result = new CompletableFuture<>();
+
+ private Pending(long started, long nanos) {
+ this.started = started;
+ this.nanos = nanos;
+ }
+
+ private long remaining() {
+ return nanos - (nanoTime.getAsLong() - started);
+ }
+ }
+
+ private final class HandlerRegistration implements MessagingSubscription {
+ private final MessagingRequestHandler
requestHandler;
+
+ private HandlerRegistration(@NotNull MessagingRequestHandler
requestHandler) {
+ this.requestHandler = requestHandler;
+ }
+
+ @Override
+ public void close() {
+ synchronized (lock) {
+ if (handler == this) {
+ handler = null;
+ }
+ }
+ }
+ }
+
+ private final class Incoming implements Runnable {
+ private final RequestKey key;
+ private final MessagingRequestReplyMessage
message;
+ private final MessagingRequestHandler
requestHandler;
+ private final long acceptedGeneration;
+ private final FutureTask task;
+
+ private Incoming(
+ @NotNull RequestKey key,
+ @NotNull MessagingRequestReplyMessage message,
+ @NotNull MessagingRequestHandler
requestHandler,
+ long acceptedGeneration
+ ) {
+ this.key = key;
+ this.message = message;
+ this.requestHandler = requestHandler;
+ this.acceptedGeneration = acceptedGeneration;
+ this.task = new FutureTask<>(() -> {
+ process();
+ return null;
+ });
+ }
+
+ @Override
+ public void run() {
+ try {
+ task.run();
+ } finally {
+ synchronized (lock) {
+ incoming.remove(key, this);
+ }
+ }
+ }
+
+ private void process() {
+ MessagingRequestReplyMessage
reply;
+ try {
+ reply = message.response(requestHandler.handle(Objects.requireNonNull(message.payload())));
+ } catch (Exception e) {
+ if (e instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ reply = message.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED);
+ }
+ synchronized (lock) {
+ if (closed || acceptedGeneration != generation) {
+ return;
+ }
+ incoming.remove(key, this);
+ }
+ sendReply(reply);
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java
new file mode 100644
index 0000000..c915310
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyException.java
@@ -0,0 +1,70 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.requestreply;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.Objects;
+
+public class MessagingRequestReplyException extends Exception {
+ public enum Reason {
+ CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, REMOTE_ERROR
+ }
+
+ private final Reason reason;
+ @Nullable
+ private final MessagingRequestReplyMessage.Error remoteError;
+
+ public MessagingRequestReplyException(@NotNull Reason reason) {
+ this(reason, null);
+ }
+
+ public MessagingRequestReplyException(@NotNull Reason reason, @Nullable Throwable cause) {
+ super(message(Objects.requireNonNull(reason)), cause);
+ this.reason = reason;
+ this.remoteError = null;
+ }
+
+ public MessagingRequestReplyException(@NotNull MessagingRequestReplyMessage.Error remoteError) {
+ super("Remote request failed: " + Objects.requireNonNull(remoteError));
+ this.reason = Reason.REMOTE_ERROR;
+ this.remoteError = remoteError;
+ }
+
+ @NotNull
+ public Reason reason() {
+ return reason;
+ }
+
+ @Nullable
+ public MessagingRequestReplyMessage.Error remoteError() {
+ return remoteError;
+ }
+
+ @NotNull
+ private static String message(@NotNull Reason reason) {
+ return switch (reason) {
+ case CLOSED -> "Request/reply endpoint is closed";
+ case DISCONNECTED -> "Request/reply transport was disconnected";
+ case OVERLOADED -> "Request/reply capacity is unavailable";
+ case SEND_FAILED -> "Request/reply message could not be sent";
+ case TIMEOUT -> "Request/reply deadline expired";
+ case REMOTE_ERROR -> "Remote request failed";
+ };
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java
new file mode 100644
index 0000000..5289c75
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyMessage.java
@@ -0,0 +1,72 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.requestreply;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.Objects;
+import java.util.UUID;
+
+public record MessagingRequestReplyMessage
(
+ @NotNull UUID requestId,
+ @NotNull UUID replyTo,
+ @NotNull Type type,
+ @Nullable P payload,
+ @Nullable Error error
+) {
+ public enum Type {
+ REQUEST, RESPONSE, ERROR
+ }
+
+ public enum Error {
+ NO_HANDLER, OVERLOADED, HANDLER_FAILED
+ }
+
+ public MessagingRequestReplyMessage {
+ Objects.requireNonNull(requestId);
+ Objects.requireNonNull(replyTo);
+ Objects.requireNonNull(type);
+ if (type == Type.REQUEST && payload == null || type == Type.ERROR && (error == null || payload != null)
+ || type != Type.ERROR && error != null) {
+ throw new IllegalArgumentException("Invalid request/reply message");
+ }
+ }
+
+ @NotNull
+ public static
MessagingRequestReplyMessage
request(@NotNull UUID requestId, @NotNull UUID replyTo, @NotNull P payload) {
+ return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.REQUEST, Objects.requireNonNull(payload), null);
+ }
+
+ @NotNull
+ public MessagingRequestReplyMessage
response(@Nullable P result) {
+ requireRequest();
+ return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.RESPONSE, result, null);
+ }
+
+ @NotNull
+ public MessagingRequestReplyMessage
failure(@NotNull Error reason) {
+ requireRequest();
+ return new MessagingRequestReplyMessage<>(requestId, replyTo, Type.ERROR, null, Objects.requireNonNull(reason));
+ }
+
+ private void requireRequest() {
+ if (type != Type.REQUEST) {
+ throw new IllegalStateException("Only a request can be answered");
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java
new file mode 100644
index 0000000..4c7a84e
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyOptions.java
@@ -0,0 +1,46 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.requestreply;
+
+import org.jkiss.code.NotNull;
+
+import java.time.Duration;
+import java.util.Objects;
+
+public record MessagingRequestReplyOptions(@NotNull Duration timeout, int maxPendingRequests, int maxConcurrentHandlers) {
+ public static final MessagingRequestReplyOptions DEFAULT = new MessagingRequestReplyOptions(Duration.ofSeconds(30), 256, 64);
+
+ public MessagingRequestReplyOptions {
+ requireTimeout(timeout);
+ if (maxPendingRequests < 1 || maxConcurrentHandlers < 1) {
+ throw new IllegalArgumentException("Request/reply limits must be positive");
+ }
+ }
+
+ static long requireTimeout(@NotNull Duration timeout) {
+ Objects.requireNonNull(timeout);
+ try {
+ long nanos = timeout.toNanos();
+ if (nanos > 0) {
+ return nanos;
+ }
+ } catch (ArithmeticException e) {
+ throw new IllegalArgumentException("Request/reply timeout is too large", e);
+ }
+ throw new IllegalArgumentException("Request/reply timeout must be positive");
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java
new file mode 100644
index 0000000..e60ce9d
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingRequestReplyTransport.java
@@ -0,0 +1,42 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.requestreply;
+
+import org.jkiss.code.NotNull;
+
+import java.util.UUID;
+import java.util.concurrent.CompletionStage;
+
+/** Provides asynchronous request/reply message transport for a selected peer. */
+public interface MessagingRequestReplyTransport
{
+ @NotNull
+ MessagingSubscription subscribe(@NotNull UUID endpointId, @NotNull Listener
listener) throws MessagingRequestReplyException;
+
+ @NotNull
+ CompletionStage send(@NotNull MessagingRequestReplyMessage message);
+
+ default void executeRequest(@NotNull Runnable request) {
+ request.run();
+ }
+
+ /** Receives endpoint messages and disconnection notifications. */
+ interface Listener
{
+ void onMessage(@NotNull MessagingRequestReplyMessage
message);
+
+ void onDisconnect();
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java
new file mode 100644
index 0000000..756a1b1
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/requestreply/MessagingSubscription.java
@@ -0,0 +1,24 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.requestreply;
+
+/** Controls the lifetime of a listener or handler registration. */
+@FunctionalInterface
+public interface MessagingSubscription extends AutoCloseable {
+ @Override
+ void close();
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java
new file mode 100644
index 0000000..c8e3741
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcCodec.java
@@ -0,0 +1,31 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.rpc;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.lang.reflect.Type;
+
+/** Encodes and decodes declared RPC types using the payload representation accepted by the endpoint transport. */
+public interface MessagingRpcCodec
{
+ @Nullable
+ P encode(@Nullable Object value, @NotNull Type type) throws Exception;
+
+ @Nullable
+ Object decode(@Nullable P payload, @NotNull Type type) throws Exception;
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java
new file mode 100644
index 0000000..b118095
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcContract.java
@@ -0,0 +1,173 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.rpc;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.lang.reflect.GenericArrayType;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.lang.reflect.WildcardType;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Future;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+final class MessagingRpcContract {
+ private static final Logger log = Logger.getLogger(MessagingRpcContract.class.getName());
+ private static final Map, Class>> BOXED = Map.of(
+ boolean.class, Boolean.class, byte.class, Byte.class, short.class, Short.class, char.class, Character.class,
+ int.class, Integer.class, long.class, Long.class, float.class, Float.class, double.class, Double.class
+ );
+
+ private final Map operations;
+
+ private MessagingRpcContract(@NotNull Map operations) {
+ this.operations = Collections.unmodifiableMap(new LinkedHashMap<>(operations));
+ }
+
+ @NotNull
+ static MessagingRpcContract create(@NotNull Class type) {
+ Objects.requireNonNull(type);
+ if (!type.isInterface() || !Modifier.isPublic(type.getModifiers()) || type.isSealed() || type.getTypeParameters().length != 0) {
+ throw new IllegalArgumentException("RPC contract must be a public, nonsealed interface with concrete types");
+ }
+ Map> declarations = new LinkedHashMap<>();
+ Set skipped = new HashSet<>();
+ for (Method method : type.getMethods()) {
+ if (Modifier.isStatic(method.getModifiers()) || method.isBridge() || method.isSynthetic() || isObjectMethod(method)) {
+ continue;
+ }
+ try {
+ validate(method);
+ } catch (IllegalArgumentException e) {
+ skipped.add(operationId(method));
+ log.log(Level.WARNING, "Skipping unsupported RPC method " + method.toGenericString(), e);
+ continue;
+ }
+ declarations.computeIfAbsent(operationId(method), ignored -> new ArrayList<>()).add(method);
+ }
+ if (declarations.isEmpty()) {
+ throw new IllegalArgumentException("RPC contract must declare at least one usable operation");
+ }
+ Map operations = new LinkedHashMap<>();
+ declarations.forEach((id, methods) -> operations.put(id, selectOperation(methods)));
+ for (Method method : type.getMethods()) {
+ if (method.isBridge() && !operations.containsKey(operationId(method)) && !skipped.contains(operationId(method))) {
+ throw new IllegalArgumentException("RPC contract has an unresolved generic bridge operation");
+ }
+ }
+ return new MessagingRpcContract<>(operations);
+ }
+
+ void requireAccessible(@NotNull Object implementation) {
+ for (Method method : operations.values()) {
+ if (!method.canAccess(implementation) && !method.trySetAccessible()) {
+ throw new IllegalArgumentException("RPC contract operation is not accessible");
+ }
+ }
+ }
+
+ @Nullable
+ Method operation(@NotNull String id) {
+ return operations.get(id);
+ }
+
+ @NotNull
+ static String operationId(@NotNull Method method) {
+ return method.getName() + Arrays.stream(method.getParameterTypes())
+ .map(Class::getTypeName).collect(Collectors.joining(",", "(", ")"));
+ }
+
+ static boolean accepts(@NotNull Class> type, @Nullable Object value) {
+ return value == null ? !type.isPrimitive() : BOXED.getOrDefault(type, type).isInstance(value);
+ }
+
+ static boolean isObjectMethod(@NotNull Method method) {
+ return switch (method.getName()) {
+ case "equals" -> Arrays.equals(method.getParameterTypes(), new Class>[] {Object.class});
+ case "hashCode", "toString" -> method.getParameterCount() == 0;
+ default -> false;
+ };
+ }
+
+ @NotNull
+ private static Method selectOperation(@NotNull List declarations) {
+ List effective = declarations.stream().filter(method -> declarations.stream().noneMatch(other ->
+ method.getDeclaringClass() != other.getDeclaringClass()
+ && method.getDeclaringClass().isAssignableFrom(other.getDeclaringClass()))).toList();
+ Method selected = effective.getFirst();
+ for (Method method : effective) {
+ if (selected.getReturnType() != method.getReturnType() && selected.getReturnType().isAssignableFrom(method.getReturnType())
+ || selected.getReturnType() == method.getReturnType() && !Modifier.isPublic(selected.getDeclaringClass().getModifiers())
+ && Modifier.isPublic(method.getDeclaringClass().getModifiers())) {
+ selected = method;
+ }
+ }
+ for (Method method : effective) {
+ if (!Arrays.equals(selected.getGenericParameterTypes(), method.getGenericParameterTypes())
+ || !method.getReturnType().isAssignableFrom(selected.getReturnType())
+ || selected.getReturnType() == method.getReturnType()
+ && !selected.getGenericReturnType().equals(method.getGenericReturnType())) {
+ throw new IllegalArgumentException("RPC contract must explicitly redeclare operations with ambiguous generic types");
+ }
+ }
+ return selected;
+ }
+
+ private static void validate(@NotNull Method method) {
+ if (method.isDefault() || method.getTypeParameters().length != 0
+ || !Arrays.stream(method.getGenericParameterTypes()).allMatch(MessagingRpcContract::isConcrete)
+ || !isConcrete(method.getGenericReturnType())
+ || CompletionStage.class.isAssignableFrom(method.getReturnType()) || Future.class.isAssignableFrom(method.getReturnType())
+ || operationId(method).length() > 1024) {
+ throw new IllegalArgumentException(
+ "RPC operations must be abstract, synchronous methods with concrete argument and result types");
+ }
+ }
+
+ private static boolean isConcrete(@NotNull Type type) {
+ if (type instanceof Class>) {
+ return true;
+ }
+ if (type instanceof ParameterizedType parameterized) {
+ return (parameterized.getOwnerType() == null || isConcrete(parameterized.getOwnerType()))
+ && Arrays.stream(parameterized.getActualTypeArguments()).allMatch(MessagingRpcContract::isConcrete);
+ }
+ if (type instanceof GenericArrayType array) {
+ return isConcrete(array.getGenericComponentType());
+ }
+ if (type instanceof WildcardType wildcard) {
+ return Arrays.stream(wildcard.getUpperBounds()).allMatch(MessagingRpcContract::isConcrete)
+ && Arrays.stream(wildcard.getLowerBounds()).allMatch(MessagingRpcContract::isConcrete);
+ }
+ return false;
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java
new file mode 100644
index 0000000..664f06d
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcEndpoint.java
@@ -0,0 +1,567 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.rpc;
+
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyException;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions;
+import com.dbeaver.messaging.requestreply.MessagingSubscription;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.lang.reflect.Type;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+public final class MessagingRpcEndpoint implements AutoCloseable {
+ private final State> state;
+
+ private MessagingRpcEndpoint(@NotNull State> state) {
+ this.state = state;
+ }
+
+ @NotNull
+ public static MessagingRpcEndpoint open(@NotNull MessagingRpcTransport
transport) {
+ return open(transport, MessagingRequestReplyOptions.DEFAULT);
+ }
+
+ @NotNull
+ public static
MessagingRpcEndpoint open(@NotNull MessagingRpcTransport
transport, @NotNull MessagingRpcCodec
defaultCodec) {
+ return open(transport, defaultCodec, MessagingRequestReplyOptions.DEFAULT);
+ }
+
+ @NotNull
+ public static
MessagingRpcEndpoint open(
+ @NotNull MessagingRpcTransport
transport,
+ @NotNull MessagingRequestReplyOptions options
+ ) {
+ return new MessagingRpcEndpoint(new State<>(transport, null, options));
+ }
+
+ @NotNull
+ public static
MessagingRpcEndpoint open(
+ @NotNull MessagingRpcTransport
transport,
+ @NotNull MessagingRpcCodec
defaultCodec,
+ @NotNull MessagingRequestReplyOptions options
+ ) {
+ return new MessagingRpcEndpoint(new State<>(transport, Objects.requireNonNull(defaultCodec), options));
+ }
+
+ @NotNull
+ public MessagingSubscription register(@NotNull String topic, @NotNull Class type, @NotNull T implementation) {
+ return register(topic, type, implementation, state.defaultCodec());
+ }
+
+ @NotNull
+ public MessagingSubscription register(
+ @NotNull String topic,
+ @NotNull Class type,
+ @NotNull T implementation,
+ @NotNull MessagingRpcCodec> codec
+ ) {
+ return state.register(topic, type, implementation, codec);
+ }
+
+ @NotNull
+ public T proxy(@NotNull String topic, @NotNull Class type) {
+ return proxy(topic, type, state.defaultCodec());
+ }
+
+ @NotNull
+ public T proxy(@NotNull String topic, @NotNull Class type, @NotNull Duration timeout) {
+ return proxy(topic, type, state.defaultCodec(), timeout);
+ }
+
+ @NotNull
+ public T proxy(@NotNull String topic, @NotNull Class type, @NotNull MessagingRpcCodec> codec) {
+ return proxy(topic, type, codec, state.options.timeout());
+ }
+
+ @NotNull
+ public T proxy(
+ @NotNull String topic,
+ @NotNull Class type,
+ @NotNull MessagingRpcCodec> codec,
+ @NotNull Duration timeout
+ ) {
+ return state.proxy(topic, type, codec, timeout);
+ }
+
+ public boolean isClosed() {
+ return state.isClosed();
+ }
+
+ public boolean isCloseComplete() {
+ return state.isCloseComplete();
+ }
+
+ @Override
+ public void close() {
+ state.close();
+ }
+
+ private static final class State {
+ private final Object lock = new Object();
+ private final MessagingRpcTransport
transport;
+ @Nullable
+ private final MessagingRpcCodec
defaultCodec;
+ private final MessagingRequestReplyOptions options;
+ private final Map topics = new HashMap<>();
+ private boolean closed;
+ private boolean closeComplete;
+
+ private State(
+ @NotNull MessagingRpcTransport transport,
+ @Nullable MessagingRpcCodec
defaultCodec,
+ @NotNull MessagingRequestReplyOptions options
+ ) {
+ this.transport = Objects.requireNonNull(transport);
+ this.defaultCodec = defaultCodec;
+ this.options = Objects.requireNonNull(options);
+ }
+
+ @NotNull
+ private MessagingRpcCodec
defaultCodec() {
+ if (defaultCodec == null) {
+ throw new IllegalStateException("RPC endpoint has no default codec");
+ }
+ return defaultCodec;
+ }
+
+ @NotNull
+ private MessagingSubscription register(
+ @NotNull String topic,
+ @NotNull Class type,
+ @NotNull T implementation,
+ @NotNull MessagingRpcCodec> codec
+ ) {
+ requireTopic(topic);
+ MessagingRpcCodec bindingCodec = bindingCodec(codec);
+ MessagingRpcContract contract = MessagingRpcContract.create(type);
+ if (!type.isInstance(Objects.requireNonNull(implementation))) {
+ throw new IllegalArgumentException("RPC implementation does not implement its contract");
+ }
+ contract.requireAccessible(implementation);
+ Topic selected = topic(topic);
+ synchronized (lock) {
+ requireOpen();
+ if (selected.registration != null) {
+ throw new IllegalStateException("RPC topic already has a registered service");
+ }
+ ServiceRegistration registration = new ServiceRegistration(selected, contract, implementation, bindingCodec);
+ selected.registration = registration;
+ return registration;
+ }
+ }
+
+ @NotNull
+ private T proxy(
+ @NotNull String topic,
+ @NotNull Class type,
+ @NotNull MessagingRpcCodec> codec,
+ @NotNull Duration timeout
+ ) {
+ requireTopic(topic);
+ requireTimeout(timeout);
+ MessagingRpcCodec bindingCodec = bindingCodec(codec);
+ MessagingRpcContract contract = MessagingRpcContract.create(type);
+ Topic selected = topic(topic);
+ synchronized (lock) {
+ requireOpen();
+ }
+ Object proxy = Proxy.newProxyInstance(type.getClassLoader(), new Class>[] {type}, (instance, method, arguments) -> {
+ if (MessagingRpcContract.isObjectMethod(method)) {
+ return switch (method.getName()) {
+ case "equals" -> instance == Objects.requireNonNull(arguments)[0];
+ case "hashCode" -> System.identityHashCode(instance);
+ case "toString" -> "RPC proxy[" + topic + ", " + type.getName() + "]";
+ default -> throw new IllegalStateException("Unsupported proxy object method");
+ };
+ }
+ Method operation = contract.operation(MessagingRpcContract.operationId(method));
+ if (operation == null) {
+ throw new IllegalArgumentException("Unsupported RPC operation: " + method.toGenericString());
+ }
+ return invoke(selected, operation, arguments == null ? new Object[0] : arguments, bindingCodec, timeout);
+ });
+ return type.cast(proxy);
+ }
+
+ @Nullable
+ private Object invoke(
+ @NotNull Topic topic,
+ @NotNull Method operation,
+ @NotNull Object[] arguments,
+ @NotNull MessagingRpcCodec codec,
+ @NotNull Duration timeout
+ ) {
+ synchronized (lock) {
+ requireOpen();
+ }
+ if (Thread.currentThread().isInterrupted()) {
+ throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED);
+ }
+ MessagingRpcMessage
request;
+ try {
+ Type[] types = operation.getGenericParameterTypes();
+ List
encoded = new ArrayList<>(arguments.length);
+ for (int index = 0; index < arguments.length; index++) {
+ encoded.add(codec.encode(arguments[index], types[index]));
+ }
+ request = MessagingRpcMessage.request(MessagingRpcContract.operationId(operation), encoded);
+ } catch (Exception e) {
+ throw codecFailure(e);
+ }
+ MessagingRpcMessage
response;
+ try {
+ response = Objects.requireNonNull(topic.requests).request(request, timeout);
+ } catch (MessagingRequestReplyException e) {
+ throw new MessagingRpcException(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED, e);
+ }
+ if (response == null || response.type() == MessagingRpcMessage.Type.REQUEST) {
+ throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE);
+ }
+ if (response.type() == MessagingRpcMessage.Type.ERROR) {
+ throw new MessagingRpcException(Objects.requireNonNull(response.error()), response.exception());
+ }
+ if (operation.getReturnType() == void.class) {
+ if (response.payload() != null) {
+ throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE);
+ }
+ return null;
+ }
+ Object result;
+ try {
+ result = codec.decode(response.payload(), operation.getGenericReturnType());
+ } catch (Exception e) {
+ throw codecFailure(e);
+ }
+ if (!MessagingRpcContract.accepts(operation.getReturnType(), result)) {
+ throw new MessagingRpcException(MessagingRpcException.Reason.INVALID_RESPONSE);
+ }
+ return result;
+ }
+
+ @NotNull
+ private MessagingRpcMessage
dispatch(@NotNull Topic topic, @NotNull MessagingRpcMessage
request) {
+ if (request.type() != MessagingRpcMessage.Type.REQUEST) {
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST);
+ }
+ ServiceRegistration registration;
+ synchronized (lock) {
+ if (closed) {
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE);
+ }
+ registration = topic.registration;
+ }
+ if (registration == null) {
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_SERVICE);
+ }
+ Method operation = registration.contract.operation(Objects.requireNonNull(request.operation()));
+ if (operation == null) {
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION);
+ }
+ if (request.arguments().size() != operation.getParameterCount()) {
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST);
+ }
+ Object[] arguments = new Object[operation.getParameterCount()];
+ Type[] types = operation.getGenericParameterTypes();
+ Class>[] parameterTypes = operation.getParameterTypes();
+ try {
+ for (int index = 0; index < arguments.length; index++) {
+ Object argument = registration.codec.decode(request.arguments().get(index), types[index]);
+ if (!MessagingRpcContract.accepts(parameterTypes[index], argument)) {
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST);
+ }
+ arguments[index] = argument;
+ }
+ } catch (Exception e) {
+ restoreInterruption(e);
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, MessagingRpcExceptionDetails.capture(e));
+ }
+ Object result;
+ try {
+ result = operation.invoke(registration.implementation, arguments);
+ } catch (InvocationTargetException e) {
+ restoreInterruption(e.getCause());
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED,
+ MessagingRpcExceptionDetails.capture(Objects.requireNonNullElse(e.getCause(), e)));
+ } catch (ReflectiveOperationException | RuntimeException e) {
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.HANDLER_FAILED, MessagingRpcExceptionDetails.capture(e));
+ }
+ if (operation.getReturnType() == void.class) {
+ return MessagingRpcMessage.response(null);
+ }
+ try {
+ return MessagingRpcMessage.response(registration.codec.encode(result, operation.getGenericReturnType()));
+ } catch (Exception e) {
+ restoreInterruption(e);
+ return MessagingRpcMessage.failure(MessagingRpcMessage.Error.CODEC_FAILED, MessagingRpcExceptionDetails.capture(e));
+ }
+ }
+
+ @NotNull
+ @SuppressWarnings("unchecked")
+ private MessagingRpcCodec
bindingCodec(@NotNull MessagingRpcCodec> codec) {
+ return (MessagingRpcCodec
) Objects.requireNonNull(codec);
+ }
+
+ private static void requireTopic(@NotNull String topic) {
+ Objects.requireNonNull(topic);
+ if (topic.isBlank() || topic.length() > 256) {
+ throw new IllegalArgumentException("Expected a nonblank RPC topic of at most 256 characters");
+ }
+ }
+
+ private static void requireTimeout(@NotNull Duration timeout) {
+ Objects.requireNonNull(timeout);
+ try {
+ if (timeout.toNanos() > 0) {
+ return;
+ }
+ } catch (ArithmeticException e) {
+ throw new IllegalArgumentException("RPC timeout is too large", e);
+ }
+ throw new IllegalArgumentException("RPC timeout must be positive");
+ }
+
+ @NotNull
+ private Topic topic(@NotNull String name) {
+ Topic selected;
+ boolean opening = false;
+ synchronized (lock) {
+ requireOpen();
+ selected = topics.get(name);
+ if (selected == null) {
+ selected = new Topic(name);
+ topics.put(name, selected);
+ opening = true;
+ }
+ }
+ if (opening) {
+ open(selected);
+ }
+ try {
+ Topic ready = selected.result.get(options.timeout().toNanos(), TimeUnit.NANOSECONDS);
+ synchronized (lock) {
+ requireOpen();
+ }
+ return ready;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new MessagingRpcException(MessagingRpcException.Reason.INTERRUPTED, e);
+ } catch (TimeoutException e) {
+ throw new MessagingRpcException(MessagingRpcException.Reason.TIMEOUT, e);
+ } catch (ExecutionException e) {
+ if (e.getCause() instanceof MessagingRpcException failure) {
+ throw failure;
+ }
+ if (e.getCause() instanceof Error failure) {
+ throw failure;
+ }
+ throw new MessagingRpcException(MessagingRpcException.Reason.SEND_FAILED, e.getCause());
+ }
+ }
+
+ private void open(@NotNull Topic topic) {
+ synchronized (lock) {
+ if (closed) {
+ finish(topic);
+ return;
+ }
+ }
+ MessagingRequestReplyEndpoint> requests = null;
+ try {
+ requests = MessagingRequestReplyEndpoint.open(Objects.requireNonNull(transport.forTopic(topic.name)), options);
+ requests.handle(request -> dispatch(topic, request));
+ synchronized (lock) {
+ topic.requests = requests;
+ topic.opening = false;
+ if (!closed) {
+ topic.result.complete(topic);
+ return;
+ }
+ }
+ } catch (Throwable e) {
+ if (requests != null) {
+ try {
+ requests.close();
+ } catch (RuntimeException | Error closeFailure) {
+ if (closeFailure != e) {
+ e.addSuppressed(closeFailure);
+ }
+ }
+ }
+ synchronized (lock) {
+ finish(topic);
+ topic.result.completeExceptionally(
+ e instanceof MessagingRequestReplyException failure ? new MessagingRpcException(failure) : e);
+ }
+ return;
+ }
+ try {
+ close(topic);
+ } catch (RuntimeException e) {
+ throw new MessagingRpcException(MessagingRpcException.Reason.CLOSED, e);
+ }
+ }
+
+ private void finish(@NotNull Topic topic) {
+ topics.remove(topic.name, topic);
+ closeComplete = closed && topics.isEmpty();
+ }
+
+ @NotNull
+ private static MessagingRpcException codecFailure(@NotNull Exception cause) {
+ restoreInterruption(cause);
+ MessagingRpcException.Reason reason = cause instanceof InterruptedException
+ ? MessagingRpcException.Reason.INTERRUPTED : MessagingRpcException.Reason.CODEC_FAILED;
+ return new MessagingRpcException(reason, cause);
+ }
+
+ private static void restoreInterruption(@Nullable Throwable failure) {
+ if (failure instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void requireOpen() {
+ if (closed) {
+ throw new MessagingRpcException(MessagingRpcException.Reason.CLOSED);
+ }
+ }
+
+ private boolean isClosed() {
+ synchronized (lock) {
+ return closed;
+ }
+ }
+
+ private boolean isCloseComplete() {
+ synchronized (lock) {
+ return closeComplete;
+ }
+ }
+
+ private void close() {
+ List stopped;
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ stopped = topics.values().stream().filter(topic -> !topic.opening).toList();
+ topics.values().forEach(topic -> {
+ topic.registration = null;
+ topic.result.completeExceptionally(new MessagingRpcException(MessagingRpcException.Reason.CLOSED));
+ });
+ closeComplete = topics.isEmpty();
+ }
+ Throwable failure = null;
+ for (Topic topic : stopped) {
+ try {
+ close(topic);
+ } catch (RuntimeException | Error e) {
+ if (failure == null) {
+ failure = e;
+ } else if (failure != e) {
+ failure.addSuppressed(e);
+ }
+ }
+ }
+ if (failure instanceof RuntimeException exception) {
+ throw exception;
+ }
+ if (failure instanceof Error error) {
+ throw error;
+ }
+ }
+
+ private void close(@NotNull Topic topic) {
+ synchronized (lock) {
+ if (topic.closing) {
+ return;
+ }
+ topic.closing = true;
+ }
+ try {
+ Objects.requireNonNull(topic.requests).close();
+ } finally {
+ synchronized (lock) {
+ finish(topic);
+ }
+ }
+ }
+
+ private final class Topic {
+ private final String name;
+ private final CompletableFuture result = new CompletableFuture<>();
+ @Nullable
+ private MessagingRequestReplyEndpoint> requests;
+ @Nullable
+ private ServiceRegistration registration;
+ private boolean opening = true;
+ private boolean closing;
+
+ private Topic(@NotNull String name) {
+ this.name = name;
+ }
+ }
+
+ private final class ServiceRegistration implements MessagingSubscription {
+ private final Topic topic;
+ private final MessagingRpcContract> contract;
+ private final Object implementation;
+ private final MessagingRpcCodec codec;
+
+ private ServiceRegistration(
+ @NotNull Topic topic,
+ @NotNull MessagingRpcContract> contract,
+ @NotNull Object implementation,
+ @NotNull MessagingRpcCodec
codec
+ ) {
+ this.topic = topic;
+ this.contract = contract;
+ this.implementation = implementation;
+ this.codec = codec;
+ }
+
+ @Override
+ public void close() {
+ synchronized (lock) {
+ if (topic.registration == this) {
+ topic.registration = null;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java
new file mode 100644
index 0000000..c90341e
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcException.java
@@ -0,0 +1,93 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.rpc;
+
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyException;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.Objects;
+
+public class MessagingRpcException extends RuntimeException {
+ public enum Reason {
+ CLOSED, DISCONNECTED, OVERLOADED, SEND_FAILED, TIMEOUT, INTERRUPTED, CODEC_FAILED, INVALID_RESPONSE, REMOTE_ERROR
+ }
+
+ private final Reason reason;
+ @Nullable
+ private final MessagingRpcMessage.Error remoteError;
+ @Nullable
+ private final MessagingRpcExceptionDetails remoteException;
+
+ public MessagingRpcException(@NotNull Reason reason) {
+ this(reason, null);
+ }
+
+ public MessagingRpcException(@NotNull Reason reason, @Nullable Throwable cause) {
+ super("RPC call failed: " + Objects.requireNonNull(reason), cause);
+ this.reason = reason;
+ this.remoteError = null;
+ this.remoteException = null;
+ }
+
+ public MessagingRpcException(@NotNull MessagingRpcMessage.Error remoteError) {
+ this(remoteError, null);
+ }
+
+ public MessagingRpcException(@NotNull MessagingRpcMessage.Error remoteError, @Nullable MessagingRpcExceptionDetails remoteException) {
+ super("Remote RPC call failed: " + Objects.requireNonNull(remoteError)
+ + (remoteException == null ? "" : ": " + remoteException.type()
+ + (remoteException.message() == null ? "" : ": " + remoteException.message())),
+ remoteException == null ? null : new RemoteException(remoteException));
+ this.reason = Reason.REMOTE_ERROR;
+ this.remoteError = remoteError;
+ this.remoteException = remoteException;
+ }
+
+ MessagingRpcException(@NotNull MessagingRequestReplyException cause) {
+ this(switch (cause.reason()) {
+ case CLOSED -> Reason.CLOSED;
+ case DISCONNECTED -> Reason.DISCONNECTED;
+ case OVERLOADED -> Reason.OVERLOADED;
+ case SEND_FAILED -> Reason.SEND_FAILED;
+ case TIMEOUT -> Reason.TIMEOUT;
+ case REMOTE_ERROR -> Reason.REMOTE_ERROR;
+ }, cause);
+ }
+
+ @NotNull
+ public Reason reason() {
+ return reason;
+ }
+
+ @Nullable
+ public MessagingRpcMessage.Error remoteError() {
+ return remoteError;
+ }
+
+ @Nullable
+ public MessagingRpcExceptionDetails remoteException() {
+ return remoteException;
+ }
+
+ private static final class RemoteException extends Exception {
+ private RemoteException(@NotNull MessagingRpcExceptionDetails details) {
+ super("Remote stack trace" + (details.truncated() ? " (truncated)" : "") + ":\n" + details.stackTrace(),
+ null, false, false);
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java
new file mode 100644
index 0000000..ddcae1e
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcExceptionDetails.java
@@ -0,0 +1,124 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.rpc;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.io.PrintWriter;
+import java.io.Writer;
+import java.util.Objects;
+
+public record MessagingRpcExceptionDetails(
+ @NotNull String type,
+ @Nullable String message,
+ @NotNull String stackTrace,
+ boolean truncated
+) {
+ public static final int MAX_TYPE_LENGTH = 256;
+ public static final int MAX_MESSAGE_LENGTH = 1024;
+ public static final int MAX_STACK_TRACE_LENGTH = 8192;
+
+ public MessagingRpcExceptionDetails {
+ Objects.requireNonNull(type);
+ Objects.requireNonNull(stackTrace);
+ if (type.isBlank() || type.length() > MAX_TYPE_LENGTH || message != null && message.length() > MAX_MESSAGE_LENGTH
+ || stackTrace.length() > MAX_STACK_TRACE_LENGTH) {
+ throw new IllegalArgumentException("Invalid RPC exception details");
+ }
+ }
+
+ @NotNull
+ static MessagingRpcExceptionDetails capture(@NotNull Throwable failure) {
+ String originalType = Objects.requireNonNull(failure).getClass().getName();
+ String originalMessage;
+ boolean incomplete = false;
+ try {
+ originalMessage = failure.getMessage();
+ } catch (RuntimeException e) {
+ originalMessage = null;
+ incomplete = true;
+ }
+ String type = prefix(originalType, MAX_TYPE_LENGTH);
+ String message = originalMessage == null ? null : prefix(originalMessage, MAX_MESSAGE_LENGTH);
+ TraceWriter trace = new TraceWriter();
+ try {
+ failure.printStackTrace(new PrintWriter(trace));
+ } catch (TraceLimit e) {
+ incomplete = true;
+ } catch (RuntimeException e) {
+ incomplete = true;
+ }
+ String stackTrace = trace.text.toString();
+ if (incomplete && !stackTrace.isEmpty() && Character.isHighSurrogate(stackTrace.charAt(stackTrace.length() - 1))) {
+ stackTrace = stackTrace.substring(0, stackTrace.length() - 1);
+ }
+ if (stackTrace.isEmpty()) {
+ stackTrace = type + (message == null ? "" : ": " + message);
+ }
+ return new MessagingRpcExceptionDetails(type, message, stackTrace,
+ incomplete || !type.equals(originalType) || !Objects.equals(message, originalMessage));
+ }
+
+ @NotNull
+ private static String prefix(@NotNull String value, int limit) {
+ if (value.length() <= limit) {
+ return value;
+ }
+ int end = Character.isHighSurrogate(value.charAt(limit - 1)) && Character.isLowSurrogate(value.charAt(limit))
+ ? limit - 1 : limit;
+ return value.substring(0, end);
+ }
+
+ private static final class TraceWriter extends Writer {
+ private final StringBuilder text = new StringBuilder();
+
+ @Override
+ public void write(@NotNull char[] value, int offset, int length) {
+ int count = Math.min(length, MAX_STACK_TRACE_LENGTH - text.length());
+ text.append(value, offset, count);
+ requireComplete(length, count);
+ }
+
+ @Override
+ public void write(@NotNull String value, int offset, int length) {
+ int count = Math.min(length, MAX_STACK_TRACE_LENGTH - text.length());
+ text.append(value, offset, offset + count);
+ requireComplete(length, count);
+ }
+
+ @Override
+ public void flush() {
+ }
+
+ @Override
+ public void close() {
+ }
+
+ private static void requireComplete(int length, int count) {
+ if (length != count) {
+ throw new TraceLimit();
+ }
+ }
+ }
+
+ private static final class TraceLimit extends RuntimeException {
+ private TraceLimit() {
+ super(null, null, false, false);
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java
new file mode 100644
index 0000000..71bbe65
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcMessage.java
@@ -0,0 +1,78 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.rpc;
+
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+public record MessagingRpcMessage
(
+ @NotNull Type type,
+ @Nullable String operation,
+ @NotNull List
arguments,
+ @Nullable P payload,
+ @Nullable Error error,
+ @Nullable MessagingRpcExceptionDetails exception
+) {
+ public enum Type {
+ REQUEST, RESPONSE, ERROR
+ }
+
+ public enum Error {
+ UNKNOWN_SERVICE, UNKNOWN_OPERATION, INVALID_REQUEST, HANDLER_FAILED, CODEC_FAILED
+ }
+
+ public MessagingRpcMessage {
+ Objects.requireNonNull(type);
+ arguments = Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(arguments)));
+ if (exception != null && type != Type.ERROR) {
+ throw new IllegalArgumentException("RPC exception details require an error response");
+ }
+ if (type == Type.REQUEST) {
+ if (operation == null || operation.isBlank() || operation.length() > 1024 || payload != null || error != null) {
+ throw new IllegalArgumentException("Invalid RPC request");
+ }
+ } else if (operation != null || !arguments.isEmpty() || type == Type.ERROR && (error == null || payload != null)
+ || type == Type.RESPONSE && error != null) {
+ throw new IllegalArgumentException("Invalid RPC response");
+ }
+ }
+
+ @NotNull
+ public static
MessagingRpcMessage
request(@NotNull String operation, @NotNull List
arguments) {
+ return new MessagingRpcMessage<>(Type.REQUEST, operation, arguments, null, null, null);
+ }
+
+ @NotNull
+ public static
MessagingRpcMessage
response(@Nullable P payload) {
+ return new MessagingRpcMessage<>(Type.RESPONSE, null, List.of(), payload, null, null);
+ }
+
+ @NotNull
+ public static
MessagingRpcMessage
failure(@NotNull Error error) {
+ return failure(error, null);
+ }
+
+ @NotNull
+ public static
MessagingRpcMessage
failure(@NotNull Error error, @Nullable MessagingRpcExceptionDetails exception) {
+ return new MessagingRpcMessage<>(Type.ERROR, null, List.of(), null, Objects.requireNonNull(error), exception);
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java
new file mode 100644
index 0000000..d5f6735
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/main/java/com/dbeaver/messaging/rpc/MessagingRpcTransport.java
@@ -0,0 +1,28 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.messaging.rpc;
+
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyException;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport;
+import org.jkiss.code.NotNull;
+
+/** Opens topic-scoped RPC transports for a selected peer. */
+@FunctionalInterface
+public interface MessagingRpcTransport
{
+ @NotNull
+ MessagingRequestReplyTransport> forTopic(@NotNull String topic) throws MessagingRequestReplyException;
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java
new file mode 100644
index 0000000..a4a13bd
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamApiTest.java
@@ -0,0 +1,93 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class DDRemoteDamApiTest {
+ @Test
+ void acceptsSubscriberIdsWithSubscriptionMetadataCharactersAndLengthBoundaries() {
+ for (String subscriberId : new String[] {"s", "service:account", "service account", "s".repeat(129), "s".repeat(256)}) {
+ Assertions.assertTrue(DDRemoteDamApi.isValidSubscriberId(subscriberId));
+ }
+ }
+
+ @Test
+ void rejectsMissingBlankAndOversizedSubscriberIds() {
+ for (String subscriberId : new String[] {null, "", " ", "\t\n", "s".repeat(257)}) {
+ Assertions.assertFalse(DDRemoteDamApi.isValidSubscriberId(subscriberId));
+ }
+ }
+
+ @Test
+ void acceptsPrintableAsciiSecretsAtBothLengthBoundaries() {
+ Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret("!".repeat(32)));
+ Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret("~".repeat(512)));
+ StringBuilder printable = new StringBuilder();
+ for (char value = 33; value <= 126; value++) {
+ printable.append(value);
+ }
+ Assertions.assertTrue(DDRemoteDamApi.isValidServiceSecret(printable.toString()));
+ }
+
+ @Test
+ void rejectsMissingShortAndOversizedSecrets() {
+ Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret(null));
+ Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret(""));
+ Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(31)));
+ Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(513)));
+ }
+
+ @Test
+ void rejectsSpacesControlCharactersAndNonAsciiSecrets() {
+ for (String invalid : new String[] {" ", "\t", "\r", "\n", "\0", "\u007f", "\u0080", "é"}) {
+ Assertions.assertFalse(DDRemoteDamApi.isValidServiceSecret("x".repeat(31) + invalid));
+ }
+ }
+
+ @Test
+ void acceptsHttpAndHttpsServiceOrigins() {
+ for (String url : new String[] {
+ "http://localhost", "http://localhost/", "https://example.com", "HTTPS://example.com/",
+ "http://127.0.0.1:1", "https://example.com:65535/", "http://[::1]", "https://[::1]:8443/"
+ }) {
+ Assertions.assertTrue(DDRemoteDamApi.isValidServiceUrl(url));
+ }
+ }
+
+ @Test
+ void rejectsMalformedServiceOrigins() {
+ for (String url : new String[] {
+ null, "", " ", "/relative", "example.com", "//example.com", "http://", "http:///", "http://bad host",
+ "ftp://example.com", "ws://example.com", "wss://example.com", "https://user@example.com",
+ "https://user:password@example.com", "https://example.com?", "https://example.com?token=value",
+ "https://example.com#", "https://example.com#fragment", "https://example.com/path", "https://example.com//",
+ "https://example.com/%2f", "https://example.com:0", "https://example.com:65536", "https://example.com:-1",
+ "https://example.com:port", "http://[::1", "http://example.com/\n"
+ }) {
+ Assertions.assertFalse(DDRemoteDamApi.isValidServiceUrl(url));
+ }
+ }
+
+ @Test
+ void boundsServiceUrlLength() {
+ String prefix = "https://";
+ Assertions.assertTrue(DDRemoteDamApi.isValidServiceUrl(prefix + "a".repeat(4096 - prefix.length())));
+ Assertions.assertFalse(DDRemoteDamApi.isValidServiceUrl(prefix + "a".repeat(4097 - prefix.length())));
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java
new file mode 100644
index 0000000..8c3118c
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/DDRemoteDamProtocolTest.java
@@ -0,0 +1,187 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api;
+
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeRequest;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamHandshakeResponse;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class DDRemoteDamProtocolTest {
+ @Test
+ void roundTripsApplicationMessagesWithTopicTypeAndPayload() {
+ JsonObject payload = new JsonObject();
+ payload.addProperty("projectId", "project-1");
+ DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "project.updated", payload);
+ String encoded = DDRemoteDamProtocol.encode(message);
+
+ assertEquals(JsonParser.parseString("""
+ {"topic":"projects","type":"project.updated","payload":{"projectId":"project-1"}}
+ """), JsonParser.parseString(encoded));
+ assertEquals(message, DDRemoteDamProtocol.decode(encoded));
+ }
+
+ @Test
+ void roundTripsHandshakeRequestWithoutAccountCredentialsOrProtocolVersion() {
+ DDRemoteDamHandshakeRequest hello = new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4");
+ String encoded = DDRemoteDamProtocol.encodeHandshakeRequest(hello);
+ JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject();
+
+ assertEquals(hello, DDRemoteDamProtocol.decodeHandshakeRequest(encoded));
+ assertEquals("hello", envelope.get("type").getAsString());
+ assertEquals(2, envelope.size());
+ assertEquals(4, envelope.getAsJsonObject("payload").size());
+ assertFalse(encoded.contains("accountId"));
+ assertFalse(encoded.contains("accessToken"));
+ assertFalse(encoded.contains("protocolVersion"));
+ }
+
+ @Test
+ void roundTripsHandshakeResponseWithOnlyTheAssignedSessionId() {
+ DDRemoteDamHandshakeResponse ready = new DDRemoteDamHandshakeResponse("session-1");
+ String encoded = DDRemoteDamProtocol.encodeHandshakeResponse(ready);
+ JsonObject envelope = JsonParser.parseString(encoded).getAsJsonObject();
+
+ assertEquals(ready, DDRemoteDamProtocol.decodeHandshakeResponse(encoded));
+ assertEquals("ready", envelope.get("type").getAsString());
+ assertEquals(1, envelope.getAsJsonObject("payload").size());
+ }
+
+ @Test
+ void keepsControlMessagesSeparateFromTopicMessages() {
+ String hello = DDRemoteDamProtocol.encodeHandshakeRequest(
+ new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4"));
+ String ready = DDRemoteDamProtocol.encodeHandshakeResponse(new DDRemoteDamHandshakeResponse("session-1"));
+
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(hello));
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(ready));
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(ready));
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(hello));
+ String event = """
+ {"topic":"projects","type":"project.updated","payload":{}}
+ """;
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(event));
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(event));
+ }
+
+ @Test
+ void rejectsControlMessagesWithAnyTopicField() {
+ for (String control : List.of(
+ DDRemoteDamProtocol.encodeHandshakeRequest(new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4")),
+ DDRemoteDamProtocol.encodeHandshakeResponse(new DDRemoteDamHandshakeResponse("session-1"))
+ )) {
+ for (String topic : List.of("\"projects\"", "null", "\"\"", "\" \"", "42", "true", "[]", "{}")) {
+ JsonObject envelope = JsonParser.parseString(control).getAsJsonObject();
+ envelope.add("topic", JsonParser.parseString(topic));
+ String message = envelope.toString();
+
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(message));
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(message));
+ }
+ }
+ }
+
+ @Test
+ void allowsControlTypeNamesInApplicationMessages() {
+ for (String type : List.of(DDRemoteDamProtocol.TYPE_HANDSHAKE_REQUEST, DDRemoteDamProtocol.TYPE_HANDSHAKE_RESPONSE)) {
+ DDRemoteDamMessage message = new DDRemoteDamMessage("projects", type, new JsonObject());
+
+ assertEquals(message, DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ }
+ }
+
+ @Test
+ void preservesUnknownApplicationTypesAndAdditionalEnvelopeFields() {
+ DDRemoteDamMessage message = DDRemoteDamProtocol.decode("""
+ {"topic":"future","type":"future.event","payload":{"value":42},"additional":true}
+ """);
+
+ assertEquals("future", message.topic());
+ assertEquals("future.event", message.type());
+ assertEquals(42, message.payload().get("value").getAsInt());
+ }
+
+ @Test
+ void rejectsMalformedEnvelopesAndMissingFields() {
+ for (String message : List.of(
+ "", "null", "[]", "true", "42", "{", "{}",
+ "{\"topic\":\"projects\",\"type\":\"updated\"}",
+ "{\"topic\":\"projects\",\"payload\":{}}",
+ "{\"type\":\"updated\",\"payload\":{}}"
+ )) {
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(message), message);
+ }
+ }
+
+ @Test
+ void rejectsNonStringAndBlankTopicAndTypeFields() {
+ for (String field : List.of("topic", "type")) {
+ for (String value : List.of("null", "true", "42", "[]", "{}", "\"\"", "\" \"")) {
+ JsonObject envelope = JsonParser.parseString("""
+ {"topic":"projects","type":"updated","payload":{}}
+ """).getAsJsonObject();
+ envelope.add(field, JsonParser.parseString(value));
+
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(envelope.toString()));
+ }
+ }
+ }
+
+ @Test
+ void rejectsNonObjectPayloads() {
+ for (String payload : List.of("null", "true", "42", "[]", "\"value\"")) {
+ String message = "{\"topic\":\"projects\",\"type\":\"updated\",\"payload\":" + payload + "}";
+
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decode(message));
+ }
+ }
+
+ @Test
+ void rejectsMissingAndIncorrectlyTypedHandshakeRequestFields() {
+ String valid = DDRemoteDamProtocol.encodeHandshakeRequest(
+ new DDRemoteDamHandshakeRequest("DI_remoteDam", "run-1", "DBeaver", "26.2.4"));
+ for (String field : List.of("appDeploymentId", "appInstanceId", "product", "version")) {
+ JsonObject missing = JsonParser.parseString(valid).getAsJsonObject();
+ missing.getAsJsonObject("payload").remove(field);
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(missing.toString()));
+
+ for (String value : List.of("null", "true", "42", "[]", "{}", "\" \"")) {
+ JsonObject invalid = JsonParser.parseString(valid).getAsJsonObject();
+ invalid.getAsJsonObject("payload").add(field, JsonParser.parseString(value));
+
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeRequest(invalid.toString()));
+ }
+ }
+ }
+
+ @Test
+ void rejectsHandshakeResponseWithoutAStringSessionId() {
+ for (String payload : List.of("{}", "{\"damSessionId\":null}", "{\"damSessionId\":42}", "{\"damSessionId\":\" \"}")) {
+ String message = "{\"type\":\"ready\",\"payload\":" + payload + "}";
+
+ assertThrows(IllegalArgumentException.class, () -> DDRemoteDamProtocol.decodeHandshakeResponse(message));
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java
new file mode 100644
index 0000000..1b8ab2e
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamCallbackClientTest.java
@@ -0,0 +1,389 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.client;
+
+import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi;
+import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.InetSocketAddress;
+import java.net.http.HttpTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Timeout(10)
+class DDRemoteDamCallbackClientTest {
+ private static final String SECRET = "remote-dam-callback-secret-2026-test";
+ private static final UUID SUBSCRIPTION_ID = UUID.randomUUID();
+ private static final UUID ACCOUNT_ID = UUID.randomUUID();
+ private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("deployment", "run", "socket");
+ private static final DDRemoteDamMessage MESSAGE = new DDRemoteDamMessage("projects", "updated",
+ JsonParser.parseString("""
+ {"nested":{"value":42},"nothing":null,"integer":9007199254740993,
+ "decimal":1.2345678901234567890123456789,"text":"Привет"}
+ """).getAsJsonObject());
+ private final AtomicReference response = new AtomicReference<>("{\"data\":{\"deliverRemoteDamMessage\":true}}");
+ private final AtomicInteger status = new AtomicInteger(200);
+ private final AtomicInteger requestCount = new AtomicInteger();
+ private final AtomicInteger responseDelay = new AtomicInteger();
+ private final AtomicReference request = new AtomicReference<>();
+ private final CountDownLatch requestReceived = new CountDownLatch(1);
+ private final AtomicReference responseGate = new AtomicReference<>();
+ private HttpServer server;
+ private DDRemoteDamCallbackClient client;
+
+ @BeforeEach
+ void start() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", this::respond);
+ server.start();
+ client = new DDRemoteDamCallbackClient(url(), SECRET);
+ }
+
+ @AfterEach
+ void stop() {
+ server.stop(0);
+ }
+
+ @Test
+ void deliversTheExactCallbackWireWithIsolatedCredentialsAndAnOpaquePayload() throws Exception {
+ for (String suffix : List.of("", "/")) {
+ client = new DDRemoteDamCallbackClient(url() + suffix, SECRET);
+ deliver();
+ CapturedRequest synchronous = request.get();
+ assertNull(deliverAsync().toCompletableFuture().get(5, TimeUnit.SECONDS));
+ CapturedRequest captured = request.get();
+ assertEquals(synchronous, captured);
+ assertEquals("POST", captured.method());
+ assertEquals("/internal/remote-dam/graphql", captured.path());
+ assertEquals(SECRET, captured.secret());
+ assertEquals("application/json", captured.contentType());
+ assertEquals("no-store", captured.cacheControl());
+ JsonObject body = captured.body();
+ assertEquals("DeliverRemoteDamMessage", body.get("operationName").getAsString());
+ String query = body.get("query").getAsString().replaceAll("\\s+", " ");
+ assertTrue(query.contains("$session: RemoteDamSessionInfoInput!"));
+ assertTrue(query.contains("$message: RemoteDamMessageInput!"));
+ assertTrue(query.contains("deliverRemoteDamMessage(subscriptionId: $subscriptionId, accountId: $accountId, "
+ + "session: $session, message: $message)"));
+ JsonObject variables = body.getAsJsonObject("variables");
+ assertEquals(Set.of("subscriptionId", "accountId", "session", "message"), variables.keySet());
+ assertEquals(SUBSCRIPTION_ID.toString(), variables.get("subscriptionId").getAsString());
+ assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString());
+ assertEquals(Set.of("appDeploymentId", "appInstanceId", "damSessionId"), variables.getAsJsonObject("session").keySet());
+ assertEquals("socket", variables.getAsJsonObject("session").get("damSessionId").getAsString());
+ assertEquals(MESSAGE.payload(), variables.getAsJsonObject("message").get("payload"));
+ JsonObject payload = variables.getAsJsonObject("message").getAsJsonObject("payload");
+ assertEquals("9007199254740993", payload.get("integer").getAsString());
+ assertEquals("1.2345678901234567890123456789", payload.get("decimal").getAsString());
+ assertTrue(payload.get("nothing").isJsonNull());
+ assertFalse(body.toString().contains(SECRET));
+ }
+ assertEquals(4, requestCount.get());
+ }
+
+ @Test
+ void returnsBeforeTheCallbackHandlerCompletesAndWaitsForItsResponse() throws Exception {
+ CountDownLatch release = new CountDownLatch(1);
+ CountDownLatch returned = new CountDownLatch(1);
+ AtomicReference> stage = new AtomicReference<>();
+ AtomicReference failure = new AtomicReference<>();
+ responseGate.set(release);
+ Thread caller = new Thread(() -> {
+ try {
+ stage.set(deliverAsync());
+ } catch (Throwable e) {
+ failure.set(e);
+ } finally {
+ returned.countDown();
+ }
+ });
+ caller.start();
+ try {
+ assertTrue(requestReceived.await(5, TimeUnit.SECONDS));
+ assertTrue(returned.await(1, TimeUnit.SECONDS));
+ assertNull(failure.get());
+ assertNotNull(stage.get());
+ assertFalse(stage.get().toCompletableFuture().isDone());
+ release.countDown();
+ assertNull(stage.get().toCompletableFuture().get(5, TimeUnit.SECONDS));
+ assertEquals(1, requestCount.get());
+ } finally {
+ release.countDown();
+ caller.join(5000);
+ }
+ }
+
+ @Test
+ void allowsDefaultAsyncDeliveryToWaitLongerThanFiveSecondsForTheHandler() throws Exception {
+ responseDelay.set(5500);
+
+ assertNull(deliverAsync().toCompletableFuture().get(8, TimeUnit.SECONDS));
+
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ void rejectsFalseAcknowledgementsAsProtocolFailures() {
+ response.set("{\"data\":{\"deliverRemoteDamMessage\":false}}");
+ assertFailureOnce();
+ DDRemoteDamException failure = assertAsyncFailureOnce();
+ assertEquals("Remote DAM callback was not accepted", failure.getMessage());
+ }
+
+ @Test
+ void rejectsMalformedEnvelopesPartialErrorsAndNonBooleanResultsWithoutRetries() {
+ for (String body : List.of("", "{", "null", "[]", "{}", "{\"data\":null}", "{\"data\":{}}",
+ "{\"data\":{\"deliverRemoteDamMessage\":null}}", "{\"data\":{\"deliverRemoteDamMessage\":\"true\"}}",
+ "{\"data\":{\"deliverRemoteDamMessage\":1}}", "{\"errors\":null}", "{\"" + SECRET + "\":[",
+ "{\"data\":{\"deliverRemoteDamMessage\":true},\"errors\":[{\"message\":\"" + SECRET + "\"}]}")) {
+ response.set(body);
+ assertFailureOnce();
+ assertAsyncFailureOnce();
+ }
+ }
+
+ @Test
+ void rejectsHttpFailuresAndNeverFollowsRedirects() {
+ for (int code : List.of(301, 302, 303, 307, 308, 400, 401, 403, 429, 500, 503)) {
+ status.set(code);
+ assertFailureOnce();
+ DDRemoteDamException failure = assertAsyncFailureOnce();
+ assertEquals("Remote DAM request failed: HTTP " + code, failure.getMessage());
+ }
+ }
+
+ @Test
+ void boundsDeliveryTimeAndDoesNotRetryTimedOutMessages() {
+ responseDelay.set(1000);
+ client = new DDRemoteDamCallbackClient(url(), SECRET, Duration.ofMillis(250));
+ assertFailureOnce();
+ }
+
+ @Test
+ void preservesTheExplicitAsyncDeliveryTimeoutAndItsNetworkCause() {
+ responseDelay.set(1000);
+ client = new DDRemoteDamCallbackClient(url(), SECRET, Duration.ofMillis(250));
+
+ DDRemoteDamException failure = assertAsyncFailureOnce();
+
+ assertEquals("Remote DAM service request failed", failure.getMessage());
+ assertInstanceOf(HttpTimeoutException.class, failure.getCause());
+ }
+
+ @Test
+ void boundsTheFullAsyncExchangeWhenHeadersArriveButTheBodyStalls() throws Exception {
+ assertStalledBodyIsCancelled(false);
+ }
+
+ @Test
+ void cancelsTheUnderlyingBodyReadWhenTheCallerCancelsDelivery() throws Exception {
+ assertStalledBodyIsCancelled(true);
+ }
+
+ @Test
+ void rejectsNullAsyncDeliveryArgumentsBeforeNetworkIo() {
+ assertThrows(NullPointerException.class, () -> client.deliverAsync(null, ACCOUNT_ID, SESSION, MESSAGE));
+ assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, null, SESSION, MESSAGE));
+ assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, null, MESSAGE));
+ assertThrows(NullPointerException.class, () -> client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, null));
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void rejectsUntrustedOriginFormsAndInvalidSecretsBeforeNetworkIo() {
+ for (String origin : List.of("", "/relative", url() + "/prefix", url() + "?query=1", url() + "#fragment",
+ "http://user:password@127.0.0.1", "ftp://127.0.0.1", "http://127.0.0.1:65536")) {
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(origin, SECRET));
+ }
+ for (String secret : List.of("", "short", " ".repeat(32), "x".repeat(31) + "\n")) {
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(url(), secret));
+ }
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamCallbackClient(url(), SECRET, Duration.ZERO));
+ assertEquals(0, requestCount.get());
+ }
+
+ private void deliver() throws DDRemoteDamException {
+ client.deliver(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, MESSAGE);
+ }
+
+ @NotNull
+ private CompletionStage deliverAsync() {
+ return client.deliverAsync(SUBSCRIPTION_ID, ACCOUNT_ID, SESSION, MESSAGE);
+ }
+
+ private void assertFailureOnce() {
+ int before = requestCount.get();
+ DDRemoteDamException failure = assertThrows(DDRemoteDamException.class, this::deliver);
+ assertFalse(failure.toString().contains(SECRET));
+ assertEquals(before + 1, requestCount.get());
+ }
+
+ @NotNull
+ private DDRemoteDamException assertAsyncFailureOnce() {
+ int before = requestCount.get();
+ CompletionStage stage = deliverAsync();
+ ExecutionException completion = assertThrows(ExecutionException.class,
+ () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS));
+ DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause());
+ StringWriter trace = new StringWriter();
+ failure.printStackTrace(new PrintWriter(trace));
+ assertFalse(trace.toString().contains(SECRET));
+ assertEquals(before + 1, requestCount.get());
+ return failure;
+ }
+
+ private void assertStalledBodyIsCancelled(boolean cancelDelivery) throws Exception {
+ CountDownLatch headersSent = new CountDownLatch(1);
+ CountDownLatch resumeBody = new CountDownLatch(1);
+ CountDownLatch probeFinished = new CountDownLatch(1);
+ AtomicReference bodyFailure = new AtomicReference<>();
+ server.createContext(DDRemoteDamApi.CALLBACK_GRAPHQL_PATH, exchange -> {
+ try {
+ exchange.getRequestBody().readAllBytes();
+ requestCount.incrementAndGet();
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(200, 0);
+ exchange.getResponseBody().write("{\"data\":{\"deliverRemoteDamMessage\":".getBytes(StandardCharsets.UTF_8));
+ exchange.getResponseBody().flush();
+ headersSent.countDown();
+ if (!resumeBody.await(5, TimeUnit.SECONDS)) {
+ return;
+ }
+ byte[] padding = " ".repeat(8192).getBytes(StandardCharsets.UTF_8);
+ for (int i = 0; i < 128; i++) {
+ exchange.getResponseBody().write(padding);
+ exchange.getResponseBody().flush();
+ Thread.sleep(10);
+ }
+ } catch (IOException e) {
+ bodyFailure.set(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ exchange.close();
+ probeFinished.countDown();
+ }
+ });
+ client = new DDRemoteDamCallbackClient(url(), SECRET,
+ cancelDelivery ? Duration.ofSeconds(5) : Duration.ofSeconds(1));
+ CompletableFuture delivery = deliverAsync().toCompletableFuture();
+ try {
+ assertTrue(headersSent.await(3, TimeUnit.SECONDS));
+ if (cancelDelivery) {
+ assertTrue(delivery.cancel(true));
+ assertTrue(delivery.isCancelled());
+ assertThrows(CancellationException.class, () -> delivery.get(1, TimeUnit.SECONDS));
+ } else {
+ ExecutionException completion = assertThrows(ExecutionException.class,
+ () -> delivery.get(3, TimeUnit.SECONDS));
+ DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause());
+ assertEquals("Remote DAM service request failed", failure.getMessage());
+ assertInstanceOf(HttpTimeoutException.class, failure.getCause());
+ StringWriter trace = new StringWriter();
+ failure.printStackTrace(new PrintWriter(trace));
+ assertFalse(trace.toString().contains(SECRET));
+ }
+ resumeBody.countDown();
+ assertTrue(probeFinished.await(5, TimeUnit.SECONDS));
+ assertNotNull(bodyFailure.get());
+ assertEquals(1, requestCount.get());
+ if (cancelDelivery) {
+ assertTrue(delivery.isCancelled());
+ assertThrows(CancellationException.class, () -> delivery.get(1, TimeUnit.SECONDS));
+ }
+ } finally {
+ resumeBody.countDown();
+ delivery.cancel(true);
+ }
+ }
+
+ @NotNull
+ private String url() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ private void respond(@NotNull HttpExchange exchange) throws IOException {
+ try {
+ request.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI().toString(),
+ exchange.getRequestHeaders().getFirst(DDRemoteDamApi.HEADER_SERVICE_TOKEN),
+ exchange.getRequestHeaders().getFirst("Content-Type"), exchange.getRequestHeaders().getFirst("Cache-Control"),
+ JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject()));
+ requestCount.incrementAndGet();
+ requestReceived.countDown();
+ CountDownLatch gate = responseGate.get();
+ if (gate != null && !gate.await(5, TimeUnit.SECONDS)) {
+ throw new IOException("Timed out waiting to release the response");
+ }
+ if (responseDelay.get() > 0) {
+ Thread.sleep(responseDelay.get());
+ }
+ byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.getResponseHeaders().set("Location", url() + "/redirected");
+ exchange.sendResponseHeaders(status.get(), bytes.length);
+ exchange.getResponseBody().write(bytes);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ exchange.close();
+ }
+ }
+
+ private record CapturedRequest(
+ @NotNull String method,
+ @NotNull String path,
+ @Nullable String secret,
+ @Nullable String contentType,
+ @Nullable String cacheControl,
+ @NotNull JsonObject body
+ ) {
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java
new file mode 100644
index 0000000..18c433b
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/client/DDRemoteDamClientTest.java
@@ -0,0 +1,1014 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.client;
+
+import com.dbeaver.datadam.dam.remote.api.DDRemoteDamApi;
+import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSession;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.function.Executable;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Timeout(30)
+class DDRemoteDamClientTest {
+ private static final UUID ACCOUNT_ID = UUID.fromString("58c487c0-de34-4b45-83bb-705cb733cbcd");
+ private static final UUID OTHER_ACCOUNT_ID = UUID.fromString("678d5732-708f-441e-bc59-719b92af1d64");
+ private static final String SERVICE_SECRET = "remote-dam-service-secret-2026-test";
+ private static final String CALLBACK_URL = "https://callback.example.com";
+ private static final String CALLBACK_SECRET = "remote-dam-callback-secret-2026-test";
+ private static final String UPSTREAM_MARKER = "private-upstream-response-marker";
+ private static final String SESSIONS_FIELD = "remoteDamSessions";
+ private static final String SESSION_ACCOUNT_FIELD = "remoteDamSessionAccount";
+ private static final String SEND_FIELD = "sendRemoteDamMessage";
+ private final AtomicReference responseJson = new AtomicReference<>("{}");
+ private final AtomicInteger responseStatus = new AtomicInteger(200);
+ private final AtomicReference redirect = new AtomicReference<>();
+ private final AtomicBoolean truncateResponse = new AtomicBoolean();
+ private final AtomicReference latestRequest = new AtomicReference<>();
+ private final AtomicInteger requestCount = new AtomicInteger();
+ private final AtomicReference handlerFailure = new AtomicReference<>();
+ private final AtomicReference requestReceived = new AtomicReference<>();
+ private final AtomicReference responseGate = new AtomicReference<>();
+ private HttpServer server;
+ private DDRemoteDamClient client;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", this::handleRequest);
+ server.start();
+ client = new DDRemoteDamClient(serviceUrl(), SERVICE_SECRET);
+ }
+
+ @AfterEach
+ void stop() {
+ if (server != null) {
+ server.stop(0);
+ }
+ assertNull(handlerFailure.get());
+ }
+
+ @Test
+ void postsSessionQueriesToTheInternalEndpointWithEitherRootUrlForm() throws DDRemoteDamException {
+ respond(SESSIONS_FIELD, new JsonArray());
+ for (String suffix : List.of("", "/")) {
+ var configured = new DDRemoteDamClient(serviceUrl() + suffix, SERVICE_SECRET);
+
+ assertEquals(List.of(), configured.getSessions(ACCOUNT_ID));
+
+ JsonObject body = posted();
+ assertEquals(Set.of("operationName", "query", "variables"), body.keySet());
+ assertEquals("RemoteDamSessions", body.get("operationName").getAsString());
+ String query = body.get("query").getAsString().replaceAll("\\s+", " ");
+ assertTrue(query.contains("query RemoteDamSessions($accountId: ID!)"));
+ assertTrue(query.contains("remoteDamSessions(accountId: $accountId)"));
+ for (String field : List.of("appDeploymentId", "appInstanceId", "damSessionId", "product", "version")) {
+ assertTrue(query.contains(field), field);
+ }
+ assertEquals(Set.of("accountId"), body.getAsJsonObject("variables").keySet());
+ assertEquals(ACCOUNT_ID.toString(), body.getAsJsonObject("variables").get("accountId").getAsString());
+ assertFalse(query.contains(ACCOUNT_ID.toString()));
+ }
+ assertEquals(2, requestCount.get());
+ }
+
+ @Test
+ void returnsAllAsyncOperationsBeforeDelayedResponsesAndParsesTheirResults() throws Exception {
+ JsonArray sessions = new JsonArray();
+ sessions.add(session("session-1"));
+ respond(SESSIONS_FIELD, sessions);
+
+ List result = awaitDelayedResponse(() -> client.getSessionsAsync(ACCOUNT_ID));
+
+ assertEquals(List.of(new DDRemoteDamSession("DI_remoteDam", "run-1", "session-1", "DBeaver", "26.2.4")), result);
+ assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0)));
+ assertEquals("RemoteDamSessions", posted().get("operationName").getAsString());
+ assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString());
+
+ respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString("\"" + ACCOUNT_ID + "\""));
+ assertEquals(ACCOUNT_ID, awaitDelayedResponse(() -> client.getSessionAccountAsync("session-1")));
+ assertEquals("RemoteDamSessionAccount", posted().get("operationName").getAsString());
+ assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString());
+
+ respond(SEND_FIELD, JsonParser.parseString("false"));
+ assertFalse(awaitDelayedResponse(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message())));
+ assertEquals("SendRemoteDamMessage", posted().get("operationName").getAsString());
+ assertEquals(ACCOUNT_ID.toString(), posted().getAsJsonObject("variables").get("accountId").getAsString());
+ assertEquals("session-1", posted().getAsJsonObject("variables").get("damSessionId").getAsString());
+
+ UUID subscriptionId = UUID.randomUUID();
+ respond("registerRemoteDamSubscription", JsonParser.parseString("60"));
+ assertEquals(60, awaitDelayedResponse(() -> client.registerSubscriptionAsync("service account", subscriptionId,
+ ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET)).intValue());
+ JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input");
+ assertEquals("RegisterRemoteDamSubscription", posted().get("operationName").getAsString());
+ assertEquals("service account", input.get("subscriberId").getAsString());
+ assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString());
+ assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString());
+ assertEquals("projects", input.get("topic").getAsString());
+ assertEquals(CALLBACK_URL, input.get("callbackUrl").getAsString());
+ assertEquals(CALLBACK_SECRET, input.get("callbackSecret").getAsString());
+ assertTrue(!input.has("damSessionId") || input.get("damSessionId").isJsonNull());
+
+ respond("unregisterRemoteDamSubscription", JsonParser.parseString("false"));
+ assertFalse(awaitDelayedResponse(() -> client.unregisterSubscriptionAsync("service account", subscriptionId)));
+ assertEquals("UnregisterRemoteDamSubscription", posted().get("operationName").getAsString());
+ assertEquals(Set.of("subscriberId", "subscriptionId"), posted().getAsJsonObject("variables").keySet());
+ assertEquals("service account", posted().getAsJsonObject("variables").get("subscriberId").getAsString());
+ assertEquals(subscriptionId.toString(), posted().getAsJsonObject("variables").get("subscriptionId").getAsString());
+ assertEquals(5, requestCount.get());
+ }
+
+ @Test
+ void sendsAsyncMessagesWithExactJsonNumbersNullsAndUtf8() throws Exception {
+ JsonObject payload = JsonParser.parseString("""
+ {"integer":9007199254740993,"decimal":1.2345678901234567890123456789,
+ "nothing":null,"nested":[null,{"text":"Привет"}]}
+ """).getAsJsonObject();
+ DDRemoteDamMessage message = new DDRemoteDamMessage("topic-\"\\\n", "type-\"\\\n", payload);
+ respond(SEND_FIELD, JsonParser.parseString("true"));
+
+ assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-\"\\\n", message).toCompletableFuture().get(5, TimeUnit.SECONDS));
+
+ JsonObject variables = posted().getAsJsonObject("variables");
+ assertEquals("session-\"\\\n", variables.get("damSessionId").getAsString());
+ JsonObject sent = variables.getAsJsonObject("message");
+ assertEquals(message.topic(), sent.get("topic").getAsString());
+ assertEquals(message.type(), sent.get("type").getAsString());
+ assertEquals(payload, sent.getAsJsonObject("payload"));
+ assertEquals("9007199254740993", sent.getAsJsonObject("payload").get("integer").getAsString());
+ assertEquals("1.2345678901234567890123456789", sent.getAsJsonObject("payload").get("decimal").getAsString());
+ assertTrue(sent.getAsJsonObject("payload").get("nothing").isJsonNull());
+ }
+
+ @Test
+ void resolvesTheSessionOwnerWithOneAuthenticatedReadAndNoDiscovery() throws DDRemoteDamException {
+ String damSessionId = "session-\"\\\n) { injectedSession }";
+ for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) {
+ respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString("\"" + accountId + "\""));
+
+ assertEquals(accountId, client.getSessionAccount(damSessionId));
+
+ JsonObject body = posted();
+ assertEquals("RemoteDamSessionAccount", body.get("operationName").getAsString());
+ String query = body.get("query").getAsString().replaceAll("\\s+", " ");
+ assertTrue(query.contains("query RemoteDamSessionAccount($damSessionId: ID!)"));
+ assertTrue(query.contains("remoteDamSessionAccount(damSessionId: $damSessionId)"));
+ assertEquals(Set.of("damSessionId"), body.getAsJsonObject("variables").keySet());
+ assertEquals(damSessionId, body.getAsJsonObject("variables").get("damSessionId").getAsString());
+ assertFalse(query.contains("injectedSession"));
+ }
+ assertEquals(2, requestCount.get());
+ }
+
+ @Test
+ void rejectsUnavailableNonStringAndMalformedSessionOwnersWithoutRetrying() {
+ for (String value : List.of("null", "true", "false", "42", "{}", "[]", "\"\"", "\" \\t\"",
+ "\"not-a-uuid\"", "\"1-1-1-1-1\"", "\"" + ACCOUNT_ID + " \"", "\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"")) {
+ respond(SESSION_ACCOUNT_FIELD, JsonParser.parseString(value));
+
+ assertRequestFails(() -> client.getSessionAccount("session-1"));
+ assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1"));
+ }
+ responseJson.set("{\"data\":{}}");
+ assertRequestFails(() -> client.getSessionAccount("session-1"));
+ assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1"));
+ }
+
+ @Test
+ void rejectsBlankAndOversizedSessionOwnerLookupIdsBeforeSendingAnyRequest() {
+ for (String damSessionId : List.of("", " ", "\t\n", "s".repeat(257))) {
+ assertThrows(IllegalArgumentException.class, () -> client.getSessionAccount(damSessionId));
+ assertThrows(IllegalArgumentException.class, () -> client.getSessionAccountAsync(damSessionId));
+ }
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void preservesDistinctConcurrentSessionsAndReturnsAnImmutableSnapshot() throws DDRemoteDamException {
+ JsonArray sessions = new JsonArray();
+ sessions.add(session("session-1"));
+ sessions.add(session("session-2"));
+ respond(SESSIONS_FIELD, sessions);
+
+ List result = client.getSessions(ACCOUNT_ID);
+
+ assertEquals(List.of(
+ new DDRemoteDamSession("DI_remoteDam", "run-1", "session-1", "DBeaver", "26.2.4"),
+ new DDRemoteDamSession("DI_remoteDam", "run-1", "session-2", "DBeaver", "26.2.4")
+ ), result);
+ assertThrows(UnsupportedOperationException.class, () -> result.add(result.get(0)));
+ assertThrows(UnsupportedOperationException.class, () -> result.set(0, result.get(1)));
+ assertThrows(UnsupportedOperationException.class, () -> result.remove(0));
+
+ respond(SESSIONS_FIELD, new JsonArray());
+ List empty = client.getSessions(ACCOUNT_ID);
+
+ assertEquals(List.of(), empty);
+ assertThrows(UnsupportedOperationException.class, () -> empty.add(result.get(0)));
+ assertEquals(2, result.size());
+ assertEquals("session-1", result.get(0).damSessionId());
+ assertEquals(2, requestCount.get());
+ }
+
+ @Test
+ void scopesBothOperationsToTheAccountOnEveryRequest() throws DDRemoteDamException {
+ String sessionsQuery = null;
+ String sendQuery = null;
+ for (UUID accountId : List.of(ACCOUNT_ID, OTHER_ACCOUNT_ID)) {
+ respond(SESSIONS_FIELD, new JsonArray());
+ assertEquals(List.of(), client.getSessions(accountId));
+ JsonObject sessionsBody = posted();
+ assertEquals(accountId.toString(), sessionsBody.getAsJsonObject("variables").get("accountId").getAsString());
+ String currentSessionsQuery = sessionsBody.get("query").getAsString();
+ if (sessionsQuery != null) {
+ assertEquals(sessionsQuery, currentSessionsQuery);
+ }
+ sessionsQuery = currentSessionsQuery;
+ assertFalse(sessionsQuery.contains(accountId.toString()));
+
+ respond(SEND_FIELD, JsonParser.parseString("true"));
+ assertTrue(client.sendMessage(accountId, "session-1", message()));
+ JsonObject sendBody = posted();
+ assertEquals(accountId.toString(), sendBody.getAsJsonObject("variables").get("accountId").getAsString());
+ assertEquals("session-1", sendBody.getAsJsonObject("variables").get("damSessionId").getAsString());
+ String currentSendQuery = sendBody.get("query").getAsString();
+ if (sendQuery != null) {
+ assertEquals(sendQuery, currentSendQuery);
+ }
+ sendQuery = currentSendQuery;
+ assertFalse(sendQuery.contains(accountId.toString()));
+ }
+ assertEquals(4, requestCount.get());
+ }
+
+ @Test
+ void sendsOpaqueMetadataAsVariablesAndPreservesTheDeepObjectPayload() throws DDRemoteDamException {
+ JsonObject nested = new JsonObject();
+ nested.addProperty("name", "quoted \"value\"");
+ nested.addProperty("path", "C:\\temp\\file");
+ JsonArray items = new JsonArray();
+ items.add(nested);
+ items.add(true);
+ items.add(42);
+ items.add(JsonNull.INSTANCE);
+ JsonObject payload = new JsonObject();
+ payload.add("items", items);
+ payload.addProperty("text", "Привет");
+ String topic = "topic-\"\\\n{ injectedTopic }";
+ String type = "type-\"\\\n{ injectedType }";
+ DDRemoteDamMessage message = new DDRemoteDamMessage(topic, type, payload);
+ respond(SEND_FIELD, JsonParser.parseString("true"));
+
+ String damSessionId = "session-\"\\\n) { injectedSession }";
+ assertTrue(client.sendMessage(ACCOUNT_ID, damSessionId, message));
+
+ JsonObject body = posted();
+ assertEquals(Set.of("operationName", "query", "variables"), body.keySet());
+ assertEquals("SendRemoteDamMessage", body.get("operationName").getAsString());
+ JsonObject variables = body.getAsJsonObject("variables");
+ assertEquals(Set.of("accountId", "damSessionId", "message"), variables.keySet());
+ assertEquals(ACCOUNT_ID.toString(), variables.get("accountId").getAsString());
+ assertEquals(damSessionId, variables.get("damSessionId").getAsString());
+ JsonObject input = variables.getAsJsonObject("message");
+ assertEquals(Set.of("topic", "type", "payload"), input.keySet());
+ assertEquals(topic, input.get("topic").getAsString());
+ assertEquals(type, input.get("type").getAsString());
+ assertEquals(payload, input.get("payload"));
+ assertTrue(input.get("payload").isJsonObject());
+ String query = body.get("query").getAsString().replaceAll("\\s+", " ");
+ assertTrue(query.contains("mutation SendRemoteDamMessage("));
+ assertTrue(query.contains("$accountId: ID!"));
+ assertTrue(query.contains("$damSessionId: ID!"));
+ assertTrue(query.contains("$message: RemoteDamMessageInput!"));
+ assertTrue(query.contains("sendRemoteDamMessage(accountId: $accountId, damSessionId: $damSessionId, message: $message)"));
+ for (String value : List.of(ACCOUNT_ID.toString(), damSessionId, topic, type, "injectedSession", "injectedTopic",
+ "injectedType", "Привет", "C:\\temp\\file")) {
+ assertFalse(query.contains(value), value);
+ }
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ void returnsTheSendBooleanWithoutAdditionalRequests() throws DDRemoteDamException {
+ for (boolean written : List.of(true, false)) {
+ respond(SEND_FIELD, JsonParser.parseString(Boolean.toString(written)));
+ int before = requestCount.get();
+
+ assertEquals(written, client.sendMessage(ACCOUNT_ID, "session-1", message()));
+
+ assertEquals(before + 1, requestCount.get());
+ assertEquals("SendRemoteDamMessage", posted().get("operationName").getAsString());
+ }
+ }
+
+ @Test
+ void registersDynamicCallbacksAsVariablesAndPreservesStableIdsAndOptionalSessionScope() throws DDRemoteDamException {
+ UUID subscriptionId = UUID.randomUUID();
+ for (String damSessionId : List.of("", "socket")) {
+ String callbackUrl = damSessionId.isEmpty() ? CALLBACK_URL : "http://127.0.0.1:8080/";
+ String callbackSecret = damSessionId.isEmpty() ? CALLBACK_SECRET : CALLBACK_SECRET + "\"\\";
+ respond("registerRemoteDamSubscription", JsonParser.parseString("60"));
+
+ assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID,
+ damSessionId.isEmpty() ? null : damSessionId, "projects", callbackUrl, callbackSecret));
+
+ JsonObject body = posted();
+ assertEquals(Set.of("operationName", "query", "variables"), body.keySet());
+ assertEquals("RegisterRemoteDamSubscription", body.get("operationName").getAsString());
+ String query = body.get("query").getAsString().replaceAll("\\s+", " ");
+ assertTrue(query.contains("$input: RemoteDamSubscriptionInput!"));
+ assertTrue(query.contains("registerRemoteDamSubscription(input: $input)"));
+ assertEquals(Set.of("input"), body.getAsJsonObject("variables").keySet());
+ JsonObject input = body.getAsJsonObject("variables").getAsJsonObject("input");
+ assertEquals("account", input.get("subscriberId").getAsString());
+ assertEquals(subscriptionId.toString(), input.get("subscriptionId").getAsString());
+ assertEquals(ACCOUNT_ID.toString(), input.get("accountId").getAsString());
+ assertEquals("projects", input.get("topic").getAsString());
+ assertEquals(callbackUrl, input.get("callbackUrl").getAsString());
+ assertTrue(callbackSecret.equals(input.get("callbackSecret").getAsString()));
+ if (damSessionId.isEmpty()) {
+ assertTrue(!input.has("damSessionId") || input.get("damSessionId").isJsonNull());
+ } else {
+ assertEquals(damSessionId, input.get("damSessionId").getAsString());
+ }
+ assertEquals(input.has("damSessionId") ? 7 : 6, input.size());
+ assertFalse(input.has("url"));
+ assertFalse(input.has("serviceSecret"));
+ assertFalse(query.contains(subscriptionId.toString()));
+ assertFalse(query.contains(callbackUrl));
+ assertFalse(query.contains(callbackSecret));
+ }
+ assertEquals(2, requestCount.get());
+ }
+
+ @Test
+ void registersTheServiceSecretAsTheCallbackSecret() throws DDRemoteDamException {
+ respond("registerRemoteDamSubscription", JsonParser.parseString("60"));
+
+ assertEquals(60, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, SERVICE_SECRET));
+
+ CapturedRequest request = latestRequest.get();
+ assertNotNull(request);
+ assertEquals(SERVICE_SECRET, request.header(DDRemoteDamApi.HEADER_SERVICE_TOKEN));
+ JsonObject body = JsonParser.parseString(request.body()).getAsJsonObject();
+ assertEquals(SERVICE_SECRET, body.getAsJsonObject("variables").getAsJsonObject("input")
+ .get("callbackSecret").getAsString());
+ assertFalse(body.get("query").getAsString().contains(SERVICE_SECRET));
+ assertEquals(1, requestCount.get());
+ }
+
+ @Test
+ void acceptsCallbackRootOriginsAndPrintableSecretBoundaries() throws DDRemoteDamException {
+ UUID subscriptionId = UUID.randomUUID();
+ respond("registerRemoteDamSubscription", JsonParser.parseString("60"));
+ for (String callbackUrl : List.of("http://127.0.0.1:8080", "https://callback.example.com/")) {
+ for (String callbackSecret : List.of("!".repeat(32), "~".repeat(512))) {
+ assertEquals(60, client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", callbackUrl, callbackSecret));
+
+ JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input");
+ assertEquals(callbackUrl, input.get("callbackUrl").getAsString());
+ assertTrue(callbackSecret.equals(input.get("callbackSecret").getAsString()));
+ }
+ }
+ assertEquals(4, requestCount.get());
+ }
+
+ @Test
+ void preservesSubscriberIdsAcrossRegistrationAndCancellation() throws DDRemoteDamException {
+ for (String subscriberId : List.of("service:account", "service account", "s".repeat(129), "s".repeat(256))) {
+ UUID subscriptionId = UUID.randomUUID();
+ respond("registerRemoteDamSubscription", JsonParser.parseString("60"));
+ assertEquals(60, client.registerSubscription(subscriberId, subscriptionId, ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertEquals(subscriberId, posted().getAsJsonObject("variables").getAsJsonObject("input")
+ .get("subscriberId").getAsString());
+
+ respond("unregisterRemoteDamSubscription", JsonParser.parseString("true"));
+ assertTrue(client.unregisterSubscription(subscriberId, subscriptionId));
+ assertEquals(subscriberId, posted().getAsJsonObject("variables").get("subscriberId").getAsString());
+ }
+ assertEquals(8, requestCount.get());
+ }
+
+ @Test
+ void rejectsMalformedSubscriptionMetadataBeforeSendingAnyRequest() {
+ UUID subscriptionId = UUID.randomUUID();
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(null, subscriptionId,
+ ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscription(null, subscriptionId));
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync(null, subscriptionId,
+ ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscriptionAsync(null, subscriptionId));
+ for (String value : List.of("", " ", "\t\n", "s".repeat(257))) {
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscription(value, subscriptionId,
+ ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscription(value, subscriptionId));
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId,
+ ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscription("account", subscriptionId,
+ ACCOUNT_ID, null, value, CALLBACK_URL, CALLBACK_SECRET));
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync(value, subscriptionId,
+ ACCOUNT_ID, null, "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertThrows(IllegalArgumentException.class, () -> client.unregisterSubscriptionAsync(value, subscriptionId));
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync("account", subscriptionId,
+ ACCOUNT_ID, value, "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertThrows(IllegalArgumentException.class, () -> client.registerSubscriptionAsync("account", subscriptionId,
+ ACCOUNT_ID, null, value, CALLBACK_URL, CALLBACK_SECRET));
+ }
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void rejectsMalformedCallbackOriginsBeforeSendingAnyRequestWithoutExposingSecrets() {
+ UUID subscriptionId = UUID.randomUUID();
+ for (String callbackUrl : new String[] {
+ null, "", " ", "/relative", "callback.example.com", "//callback.example.com", "http://", "http:///",
+ "ftp://callback.example.com", "ws://callback.example.com", "wss://callback.example.com",
+ CALLBACK_URL + "/internal/remote-dam/graphql", CALLBACK_URL + "/prefix/", CALLBACK_URL + "//",
+ CALLBACK_URL + "/%2f", CALLBACK_URL + "?secret=" + CALLBACK_SECRET, CALLBACK_URL + "#fragment",
+ "http://user:" + CALLBACK_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536",
+ "http://127.0.0.1:-1", "http://127.0.0.1:port", "http://[::1", "https://" + "a".repeat(4096)
+ }) {
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", callbackUrl, CALLBACK_SECRET));
+ assertFalse(failure.toString().contains(CALLBACK_SECRET));
+ assertFalse(failure.toString().contains(SERVICE_SECRET));
+ IllegalArgumentException asyncFailure = assertThrows(IllegalArgumentException.class,
+ () -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", callbackUrl, CALLBACK_SECRET));
+ assertFalse(asyncFailure.toString().contains(CALLBACK_SECRET));
+ assertFalse(asyncFailure.toString().contains(SERVICE_SECRET));
+ }
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void rejectsInvalidCallbackSecretsBeforeSendingAnyRequest() {
+ UUID subscriptionId = UUID.randomUUID();
+ for (String callbackSecret : new String[] {
+ null, "", "s".repeat(31), "s".repeat(513), " ".repeat(32), "s".repeat(31) + "\n",
+ "s".repeat(31) + "\t", "s".repeat(31) + "\r", "s".repeat(31) + "\0",
+ "s".repeat(31) + "\u007f", "s".repeat(31) + "é"
+ }) {
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, callbackSecret));
+ assertFalse(failure.toString().contains(CALLBACK_SECRET));
+ assertFalse(failure.toString().contains(SERVICE_SECRET));
+ IllegalArgumentException asyncFailure = assertThrows(IllegalArgumentException.class,
+ () -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, callbackSecret));
+ assertFalse(asyncFailure.toString().contains(CALLBACK_SECRET));
+ assertFalse(asyncFailure.toString().contains(SERVICE_SECRET));
+ }
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void unregistersOnlyTheStableSubscriberAndSubscriptionId() throws DDRemoteDamException {
+ UUID subscriptionId = UUID.randomUUID();
+ for (boolean removed : List.of(true, false)) {
+ respond("unregisterRemoteDamSubscription", JsonParser.parseString(Boolean.toString(removed)));
+
+ assertEquals(removed, client.unregisterSubscription("storage", subscriptionId));
+
+ JsonObject body = posted();
+ assertEquals("UnregisterRemoteDamSubscription", body.get("operationName").getAsString());
+ JsonObject variables = body.getAsJsonObject("variables");
+ assertEquals(Set.of("subscriberId", "subscriptionId"), variables.keySet());
+ assertEquals("storage", variables.get("subscriberId").getAsString());
+ assertEquals(subscriptionId.toString(), variables.get("subscriptionId").getAsString());
+ }
+ }
+
+ @Test
+ void acceptsBothSubscriptionLeaseBoundaries() throws Exception {
+ for (int lease : List.of(15, 300)) {
+ respond("registerRemoteDamSubscription", JsonParser.parseString(Integer.toString(lease)));
+ assertEquals(lease, client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertEquals(lease, client.registerSubscriptionAsync("s".repeat(256), UUID.randomUUID(), ACCOUNT_ID, "socket",
+ "projects", CALLBACK_URL, CALLBACK_SECRET).toCompletableFuture().get(5, TimeUnit.SECONDS).intValue());
+ JsonObject input = posted().getAsJsonObject("variables").getAsJsonObject("input");
+ assertEquals("s".repeat(256), input.get("subscriberId").getAsString());
+ assertEquals("socket", input.get("damSessionId").getAsString());
+ }
+ }
+
+ @Test
+ void rejectsInvalidLeaseValuesAndMalformedSubscriptionEnvelopesWithoutRetrying() {
+ UUID subscriptionId = UUID.randomUUID();
+ for (String value : List.of("null", "true", "\"60\"", "14", "301", "60.5", "1e100", "{}", "[]")) {
+ respond("registerRemoteDamSubscription", JsonParser.parseString(value));
+ assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ }
+ for (String value : List.of("null", "\"true\"", "60", "{}", "[]")) {
+ respond("unregisterRemoteDamSubscription", JsonParser.parseString(value));
+ assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId));
+ assertAsyncRequestFails(() -> client.unregisterSubscriptionAsync("account", subscriptionId));
+ }
+ for (String body : List.of("{", "null", "{}", "{\"data\":null}",
+ "{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + CALLBACK_SECRET + "\"}]}")) {
+ responseJson.set(body);
+ assertRequestFails(() -> client.registerSubscription("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertRequestFails(() -> client.unregisterSubscription("account", subscriptionId));
+ assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", subscriptionId, ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertAsyncRequestFails(() -> client.unregisterSubscriptionAsync("account", subscriptionId));
+ }
+ }
+
+ @Test
+ void rejectsTheWholeListWhenAnyRequiredSessionFieldIsInvalid() {
+ for (String field : List.of("appDeploymentId", "appInstanceId", "damSessionId", "product", "version")) {
+ JsonObject missing = session("session-2");
+ missing.remove(field);
+ assertInvalidSession(missing);
+ for (String value : List.of("null", "true", "42", "[]", "{}", "\"\"", "\" \\t\\n\"")) {
+ JsonObject invalid = session("session-2");
+ invalid.add(field, JsonParser.parseString(value));
+ assertInvalidSession(invalid);
+ }
+ }
+ }
+
+ @Test
+ void rejectsNonArraySessionResultsAndNonObjectEntries() {
+ for (String value : List.of(
+ "null", "true", "42", "\"sessions\"", "{}", "[null]", "[true]", "[42]", "[\"session\"]", "[[]]"
+ )) {
+ respond(SESSIONS_FIELD, JsonParser.parseString(value));
+
+ assertRequestFails(() -> client.getSessions(ACCOUNT_ID));
+ assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID));
+ }
+ }
+
+ @Test
+ void rejectsMalformedJsonRootsAndMissingDataForBothOperations() {
+ for (String body : List.of(
+ "", "{", "not-json", "null", "[]", "true", "42", "\"root\"", "{}",
+ "{\"data\":null}", "{\"data\":[]}", "{\"data\":true}", "{\"data\":{}}",
+ "{\"data\":{\"remoteDamSessions\":null,\"sendRemoteDamMessage\":null}}",
+ "{\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\":["
+ )) {
+ responseJson.set(body);
+
+ assertRequestFails(() -> client.getSessions(ACCOUNT_ID));
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+ assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID));
+ assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()));
+ }
+ }
+
+ @Test
+ void rejectsMalformedErrorsAndGraphqlErrorsEvenAlongsideUsablePartialData() {
+ JsonObject upstreamError = new JsonObject();
+ upstreamError.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET);
+ JsonArray errors = new JsonArray();
+ errors.add(upstreamError);
+ for (JsonElement value : List.of(
+ JsonNull.INSTANCE, new JsonObject(), JsonParser.parseString("true"), JsonParser.parseString("42"),
+ JsonParser.parseString("\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\""), JsonParser.parseString("[null]"), errors
+ )) {
+ JsonObject envelope = successfulEnvelope();
+ envelope.add("errors", value);
+ responseJson.set(envelope.toString());
+
+ assertRequestFails(() -> client.getSessions(ACCOUNT_ID));
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+ assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID));
+ DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()));
+ assertEquals("Remote DAM GraphQL request failed", failure.getMessage());
+ }
+ }
+
+ @Test
+ void acceptsAnEmptyGraphqlErrorsArray() throws Exception {
+ JsonObject envelope = successfulEnvelope();
+ envelope.add("errors", new JsonArray());
+ responseJson.set(envelope.toString());
+
+ assertEquals(List.of(), client.getSessions(ACCOUNT_ID));
+ assertTrue(client.sendMessage(ACCOUNT_ID, "session-1", message()));
+ assertEquals(List.of(), client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS));
+ assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS));
+ assertEquals(4, requestCount.get());
+ }
+
+ @Test
+ void rejectsSendResultsThatAreNotJsonBooleans() {
+ for (String value : List.of("null", "\"true\"", "\"false\"", "0", "1", "{}", "[]")) {
+ respond(SEND_FIELD, JsonParser.parseString(value));
+
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+ assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()));
+ }
+ }
+
+ @Test
+ void acceptsSuccessfulHttpStatusesThroughoutTheTwoHundredRange() throws Exception {
+ responseJson.set(successfulEnvelope().toString());
+ for (int status : List.of(200, 201, 202, 299)) {
+ responseStatus.set(status);
+ assertEquals(List.of(), client.getSessions(ACCOUNT_ID));
+ assertEquals(List.of(), client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void rejectsHttpFailuresWithoutExposingTheResponseOrRetrying() {
+ JsonObject envelope = successfulEnvelope();
+ envelope.addProperty("message", UPSTREAM_MARKER + SERVICE_SECRET + CALLBACK_SECRET);
+ responseJson.set(envelope.toString());
+ for (int status : List.of(204, 300, 400, 401, 403, 404, 429, 500, 503)) {
+ responseStatus.set(status);
+
+ assertRequestFails(() -> client.getSessions(ACCOUNT_ID));
+ assertRequestFails(() -> client.getSessionAccount("session-1"));
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+ assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()));
+ assertEquals(status == 204 ? "Invalid Remote DAM response" : "Remote DAM request failed: HTTP " + status,
+ failure.getMessage());
+ }
+ }
+
+ @Test
+ void neverFollowsRedirectsOrForwardsTheServiceSecret() throws IOException {
+ var forwarded = new AtomicInteger();
+ var forwardedSecret = new AtomicReference();
+ HttpServer destination = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ destination.createContext("/", exchange -> {
+ forwarded.incrementAndGet();
+ forwardedSecret.set(exchange.getRequestHeaders().getFirst(DDRemoteDamApi.HEADER_SERVICE_TOKEN));
+ try {
+ exchange.sendResponseHeaders(200, -1);
+ } catch (IOException e) {
+ handlerFailure.compareAndSet(null, e);
+ } finally {
+ exchange.close();
+ }
+ });
+ destination.start();
+ try {
+ redirect.set("http://127.0.0.1:" + destination.getAddress().getPort() + "/destination");
+ responseJson.set(successfulEnvelope().toString());
+ for (int status : List.of(301, 302, 303, 307, 308)) {
+ responseStatus.set(status);
+
+ assertRequestFails(() -> client.getSessions(ACCOUNT_ID));
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+ assertRequestFails(() -> client.registerSubscription("account", UUID.randomUUID(), ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertAsyncRequestFails(() -> client.getSessionAccountAsync("session-1"));
+ assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()));
+ assertAsyncRequestFails(() -> client.registerSubscriptionAsync("account", UUID.randomUUID(), ACCOUNT_ID, null,
+ "projects", CALLBACK_URL, CALLBACK_SECRET));
+ assertEquals(0, forwarded.get());
+ assertNull(forwardedSecret.get());
+ }
+ } finally {
+ destination.stop(0);
+ }
+ }
+
+ @Test
+ void neverRetriesOrReplaysFailedMutationsAfterRecovery() throws DDRemoteDamException {
+ responseJson.set("{\"errors\":[{\"message\":\"" + UPSTREAM_MARKER + SERVICE_SECRET + "\"}]}");
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+
+ responseJson.set(successfulEnvelope().toString());
+ responseStatus.set(503);
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+
+ responseStatus.set(200);
+ truncateResponse.set(true);
+ assertRequestFails(() -> client.sendMessage(ACCOUNT_ID, "session-1", message()));
+
+ truncateResponse.set(false);
+ assertTrue(client.sendMessage(ACCOUNT_ID, "session-1", message()));
+ assertEquals(4, requestCount.get());
+ }
+
+ @Test
+ void preservesAsyncNetworkFailureCausesAndAllowsSubsequentRequests() throws Exception {
+ responseJson.set(successfulEnvelope().toString());
+ truncateResponse.set(true);
+
+ DDRemoteDamException failure = assertAsyncRequestFails(() -> client.sendMessageAsync(ACCOUNT_ID, "session-1", message()));
+
+ assertEquals("Remote DAM service request failed", failure.getMessage());
+ assertInstanceOf(IOException.class, failure.getCause());
+ truncateResponse.set(false);
+ assertTrue(client.sendMessageAsync(ACCOUNT_ID, "session-1", message()).toCompletableFuture().get(5, TimeUnit.SECONDS));
+ assertEquals(2, requestCount.get());
+ }
+
+ @Test
+ void reportsAsyncConnectionFailuresThroughTheStageWithTheirCause() {
+ server.stop(0);
+ server = null;
+
+ ExecutionException completion = assertThrows(ExecutionException.class,
+ () -> client.getSessionsAsync(ACCOUNT_ID).toCompletableFuture().get(5, TimeUnit.SECONDS));
+
+ DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause());
+ assertEquals("Remote DAM service request failed", failure.getMessage());
+ assertInstanceOf(IOException.class, failure.getCause());
+ assertSafeFailure(failure);
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void rejectsInvalidServiceOriginsBeforeSendingAnyRequest() {
+ for (String url : List.of(
+ "", "/relative", "127.0.0.1", "//127.0.0.1", "http:///", "http://",
+ "ftp://127.0.0.1", "ws://127.0.0.1", "wss://127.0.0.1",
+ serviceUrl() + "/internal/graphql", serviceUrl() + "/ws", serviceUrl() + "/prefix/", serviceUrl() + "//",
+ serviceUrl() + "/%2f", serviceUrl() + "?secret=" + SERVICE_SECRET, serviceUrl() + "#fragment",
+ "http://user:" + SERVICE_SECRET + "@127.0.0.1", "http://127.0.0.1:0", "http://127.0.0.1:65536"
+ )) {
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> new DDRemoteDamClient(url, SERVICE_SECRET), url);
+ assertFalse(failure.toString().contains(SERVICE_SECRET));
+ }
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void acceptsHttpAndHttpsRootOriginsAndPrintableSecretBoundaries() {
+ for (String url : List.of(serviceUrl(), serviceUrl() + "/", "https://127.0.0.1", "https://127.0.0.1:8443/")) {
+ for (String secret : List.of("!".repeat(32), "~".repeat(512))) {
+ assertDoesNotThrow(() -> new DDRemoteDamClient(url, secret));
+ }
+ }
+ assertEquals(0, requestCount.get());
+ }
+
+ @Test
+ void rejectsInvalidSecretsAndBlankSessionIdsBeforeSendingAnyRequest() {
+ for (String secret : List.of("", "s".repeat(31), "s".repeat(513), " ".repeat(32),
+ "s".repeat(31) + "\n", "s".repeat(31) + "\t", "s".repeat(31) + "\u007f", "s".repeat(31) + "é")) {
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamClient(serviceUrl(), secret));
+ }
+ for (String damSessionId : List.of("", " ", "\t\n")) {
+ assertThrows(IllegalArgumentException.class, () -> client.sendMessage(ACCOUNT_ID, damSessionId, message()));
+ assertThrows(IllegalArgumentException.class, () -> client.sendMessageAsync(ACCOUNT_ID, damSessionId, message()));
+ }
+ assertThrows(NullPointerException.class, () -> client.getSessionsAsync(null));
+ assertThrows(NullPointerException.class, () -> client.getSessionAccountAsync(null));
+ assertThrows(NullPointerException.class, () -> client.sendMessageAsync(null, "session-1", message()));
+ assertThrows(NullPointerException.class, () -> client.sendMessageAsync(ACCOUNT_ID, "session-1", null));
+ assertEquals(0, requestCount.get());
+ }
+
+ @Nullable
+ private T awaitDelayedResponse(@NotNull Supplier> action) throws Exception {
+ CountDownLatch received = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ CountDownLatch returned = new CountDownLatch(1);
+ AtomicReference> stage = new AtomicReference<>();
+ AtomicReference failure = new AtomicReference<>();
+ requestReceived.set(received);
+ responseGate.set(release);
+ Thread caller = new Thread(() -> {
+ try {
+ stage.set(action.get());
+ } catch (Throwable e) {
+ failure.set(e);
+ } finally {
+ returned.countDown();
+ }
+ });
+ caller.start();
+ try {
+ assertTrue(received.await(5, TimeUnit.SECONDS));
+ assertTrue(returned.await(1, TimeUnit.SECONDS));
+ assertNull(failure.get());
+ assertNotNull(stage.get());
+ assertFalse(stage.get().toCompletableFuture().isDone());
+ release.countDown();
+ return stage.get().toCompletableFuture().get(5, TimeUnit.SECONDS);
+ } finally {
+ release.countDown();
+ caller.join(5000);
+ requestReceived.set(null);
+ responseGate.set(null);
+ }
+ }
+
+ private void handleRequest(@NotNull HttpExchange exchange) {
+ try {
+ Map> headers = new HashMap<>();
+ exchange.getRequestHeaders().forEach((name, values) -> headers.put(name, List.copyOf(values)));
+ latestRequest.set(new CapturedRequest(exchange.getRequestMethod(), exchange.getRequestURI(), Map.copyOf(headers),
+ new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
+ requestCount.incrementAndGet();
+ CountDownLatch received = requestReceived.get();
+ CountDownLatch gate = responseGate.get();
+ if (received != null) {
+ received.countDown();
+ }
+ if (gate != null && !gate.await(5, TimeUnit.SECONDS)) {
+ throw new IOException("Timed out waiting to release the response");
+ }
+ String location = redirect.get();
+ if (location != null) {
+ exchange.getResponseHeaders().set("Location", location);
+ }
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ int status = responseStatus.get();
+ if (status == 204) {
+ exchange.sendResponseHeaders(status, -1);
+ } else {
+ byte[] bytes = responseJson.get().getBytes(StandardCharsets.UTF_8);
+ boolean truncated = truncateResponse.get();
+ if (truncated) {
+ exchange.getResponseHeaders().set("Connection", "close");
+ }
+ exchange.sendResponseHeaders(status, bytes.length + (truncated ? 16 : 0));
+ exchange.getResponseBody().write(bytes);
+ }
+ } catch (IOException e) {
+ handlerFailure.compareAndSet(null, e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ handlerFailure.compareAndSet(null, new IOException(e));
+ } finally {
+ exchange.close();
+ }
+ }
+
+ @NotNull
+ private String serviceUrl() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ @NotNull
+ private JsonObject posted() {
+ CapturedRequest request = latestRequest.get();
+ assertNotNull(request);
+ assertEquals("POST", request.method());
+ assertEquals(DDRemoteDamApi.GRAPHQL_PATH, request.uri().toString());
+ assertEquals("application/json", request.header("Content-Type"));
+ assertEquals("application/json", request.header("Accept"));
+ assertEquals("no-store", request.header("Cache-Control"));
+ assertEquals(SERVICE_SECRET, request.header(DDRemoteDamApi.HEADER_SERVICE_TOKEN));
+ assertNull(request.header("Authorization"));
+ assertNull(request.header("Cookie"));
+ assertFalse(request.body().contains(SERVICE_SECRET));
+ assertFalse(request.uri().toString().contains(SERVICE_SECRET));
+ return JsonParser.parseString(request.body()).getAsJsonObject();
+ }
+
+ private void respond(@NotNull String field, @NotNull JsonElement value) {
+ JsonObject data = new JsonObject();
+ data.add(field, value);
+ JsonObject envelope = new JsonObject();
+ envelope.add("data", data);
+ responseJson.set(envelope.toString());
+ }
+
+ private void assertInvalidSession(@NotNull JsonObject invalid) {
+ JsonArray sessions = new JsonArray();
+ sessions.add(session("session-1"));
+ sessions.add(invalid);
+ respond(SESSIONS_FIELD, sessions);
+ assertRequestFails(() -> client.getSessions(ACCOUNT_ID));
+ assertAsyncRequestFails(() -> client.getSessionsAsync(ACCOUNT_ID));
+ }
+
+ private void assertRequestFails(@NotNull Executable action) {
+ int before = requestCount.get();
+ DDRemoteDamException failure = assertThrows(DDRemoteDamException.class, action);
+ assertEquals(before + 1, requestCount.get());
+ assertSafeFailure(failure);
+ }
+
+ @NotNull
+ private DDRemoteDamException assertAsyncRequestFails(@NotNull Supplier extends CompletionStage>> action) {
+ int before = requestCount.get();
+ CompletionStage> stage = action.get();
+ ExecutionException completion = assertThrows(ExecutionException.class,
+ () -> stage.toCompletableFuture().get(5, TimeUnit.SECONDS));
+ DDRemoteDamException failure = assertInstanceOf(DDRemoteDamException.class, completion.getCause());
+ assertEquals(before + 1, requestCount.get());
+ assertSafeFailure(failure);
+ return failure;
+ }
+
+ private static void assertSafeFailure(@NotNull DDRemoteDamException failure) {
+ StringWriter trace = new StringWriter();
+ failure.printStackTrace(new PrintWriter(trace));
+ assertFalse(trace.toString().contains(SERVICE_SECRET));
+ assertFalse(trace.toString().contains(CALLBACK_SECRET));
+ assertFalse(trace.toString().contains(UPSTREAM_MARKER));
+ }
+
+ @NotNull
+ private static JsonObject session(@NotNull String damSessionId) {
+ JsonObject session = new JsonObject();
+ session.addProperty("appDeploymentId", "DI_remoteDam");
+ session.addProperty("appInstanceId", "run-1");
+ session.addProperty("damSessionId", damSessionId);
+ session.addProperty("product", "DBeaver");
+ session.addProperty("version", "26.2.4");
+ return session;
+ }
+
+ @NotNull
+ private static JsonObject successfulEnvelope() {
+ JsonObject data = new JsonObject();
+ data.add(SESSIONS_FIELD, new JsonArray());
+ data.addProperty(SEND_FIELD, true);
+ JsonObject envelope = new JsonObject();
+ envelope.add("data", data);
+ return envelope;
+ }
+
+ @NotNull
+ private static DDRemoteDamMessage message() {
+ return new DDRemoteDamMessage("projects", "project.updated", new JsonObject());
+ }
+
+ private record CapturedRequest(
+ @NotNull String method,
+ @NotNull URI uri,
+ @NotNull Map> headers,
+ @NotNull String body
+ ) {
+ @Nullable
+ private String header(@NotNull String name) {
+ return headers.entrySet().stream()
+ .filter(entry -> name.equalsIgnoreCase(entry.getKey()))
+ .map(entry -> entry.getValue().get(0))
+ .findFirst()
+ .orElse(null);
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java
new file mode 100644
index 0000000..66ec5ae
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamMessageTest.java
@@ -0,0 +1,62 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.model;
+
+import com.google.gson.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class DDRemoteDamMessageTest {
+ @Test
+ void isolatesThePayloadFromChangesToTheOriginalObject() {
+ JsonObject project = new JsonObject();
+ project.addProperty("name", "original");
+ JsonObject payload = new JsonObject();
+ payload.add("project", project);
+ DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "updated", payload);
+
+ project.addProperty("name", "changed");
+
+ assertEquals("original", message.payload().getAsJsonObject("project").get("name").getAsString());
+ }
+
+ @Test
+ void isolatesListenersFromEachOthersPayloadChanges() {
+ JsonObject payload = new JsonObject();
+ payload.addProperty("projectId", "project-1");
+ DDRemoteDamMessage message = new DDRemoteDamMessage("projects", "updated", payload);
+
+ message.payload().addProperty("projectId", "changed");
+
+ assertEquals("project-1", message.payload().get("projectId").getAsString());
+ }
+
+ @Test
+ void rejectsBlankRoutingFields() {
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamMessage(" ", "updated", new JsonObject()));
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamMessage("projects", " ", new JsonObject()));
+ }
+
+ @Test
+ void rejectsMissingSessionIdentifiers() {
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo(" ", "run-1", "session-1"));
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo("DI_remoteDam", " ", "session-1"));
+ assertThrows(IllegalArgumentException.class, () -> new DDRemoteDamSessionInfo("DI_remoteDam", "run-1", " "));
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java
new file mode 100644
index 0000000..2346381
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/model/DDRemoteDamSessionTest.java
@@ -0,0 +1,47 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.model;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class DDRemoteDamSessionTest {
+ @Test
+ void preservesSeparateDeploymentRunAndSocketIdentities() {
+ DDRemoteDamSession session = new DDRemoteDamSession("deployment", "run", "socket", "DBeaver", "26.2");
+
+ Assertions.assertEquals("deployment", session.appDeploymentId());
+ Assertions.assertEquals("run", session.appInstanceId());
+ Assertions.assertEquals("socket", session.damSessionId());
+ Assertions.assertEquals("DBeaver", session.product());
+ Assertions.assertEquals("26.2", session.version());
+ Assertions.assertNotEquals(session, new DDRemoteDamSession("deployment", "run", "another-socket", "DBeaver", "26.2"));
+ }
+
+ @Test
+ void rejectsMissingOrBlankMetadataInEveryField() {
+ for (int field = 0; field < 5; field++) {
+ for (String invalid : new String[] {null, "", " ", "\t\n"}) {
+ String[] values = {"deployment", "run", "socket", "DBeaver", "26.2"};
+ values[field] = invalid;
+
+ Assertions.assertThrows(IllegalArgumentException.class, () ->
+ new DDRemoteDamSession(values[0], values[1], values[2], values[3], values[4]));
+ }
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java
new file mode 100644
index 0000000..d365811
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/requestreply/DDRemoteDamRequestReplyTransportTest.java
@@ -0,0 +1,819 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.requestreply;
+
+import com.dbeaver.datadam.dam.remote.api.exception.DDRemoteDamException;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyException;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport;
+import com.dbeaver.messaging.requestreply.MessagingSubscription;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+
+@Timeout(10)
+class DDRemoteDamRequestReplyTransportTest {
+ private static final String TOPIC = "projects";
+ private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "socket");
+
+ @Test
+ void exchangesRequestsInBothDirectionsThroughExistingMessageChannels() throws Exception {
+ FakeChannel callerChannel = new FakeChannel();
+ FakeChannel handlerChannel = new FakeChannel();
+ callerChannel.forward = message -> handlerChannel.deliver(SESSION, message);
+ handlerChannel.forward = message -> callerChannel.deliver(SESSION, message);
+ JsonObject payload = new JsonObject();
+ payload.addProperty("value", "request");
+ try (var handler = MessagingRequestReplyEndpoint.open(DDRemoteDamRequestReplyTransport.forChannel(handlerChannel, TOPIC));
+ var handlerRegistration = handler.handle(request -> {
+ request.addProperty("handled", true);
+ return request;
+ });
+ var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRequestReplyTransport.forChannel(callerChannel, TOPIC));
+ var callerRegistration = caller.handle(request -> null)) {
+ JsonObject response = caller.request(payload, Duration.ofSeconds(2));
+
+ Assertions.assertNotNull(response);
+ Assertions.assertEquals("request", response.get("value").getAsString());
+ Assertions.assertTrue(response.get("handled").getAsBoolean());
+ Assertions.assertFalse(payload.has("handled"));
+ Assertions.assertNull(handler.request(new JsonObject(), Duration.ofSeconds(2)));
+ }
+ Assertions.assertFalse(callerChannel.closed);
+ Assertions.assertFalse(handlerChannel.closed);
+ Assertions.assertTrue(callerChannel.observers.isEmpty());
+ Assertions.assertTrue(handlerChannel.observers.isEmpty());
+ Assertions.assertTrue(callerChannel.subscriptions.stream().noneMatch(subscription -> subscription.active));
+ Assertions.assertTrue(handlerChannel.subscriptions.stream().noneMatch(subscription -> subscription.active));
+ }
+
+ @Test
+ void routesRequestsAndRepliesToUniqueEndpointTopics() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport first = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ DDRemoteDamRequestReplyTransport second = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID firstId = UUID.randomUUID();
+ UUID secondId = UUID.randomUUID();
+ Received firstReceived = new Received();
+ Received secondReceived = new Received();
+ try (var firstSubscription = first.subscribe(firstId, firstReceived);
+ var secondSubscription = second.subscribe(secondId, secondReceived)) {
+ Assertions.assertEquals(4, channel.subscriptions.size());
+ String firstReplyTopic = channel.subscriptions.get(0).topic;
+ String secondReplyTopic = channel.subscriptions.get(2).topic;
+ Assertions.assertNotEquals(firstReplyTopic, secondReplyTopic);
+ Assertions.assertTrue(firstReplyTopic.endsWith(firstId.toString()));
+ Assertions.assertTrue(secondReplyTopic.endsWith(secondId.toString()));
+ Assertions.assertEquals(TOPIC, channel.subscriptions.get(1).topic);
+ Assertions.assertEquals(TOPIC, channel.subscriptions.get(3).topic);
+ MessagingRequestReplyMessage request = request(firstId);
+ first.send(request).toCompletableFuture().join();
+ DDRemoteDamMessage sent = channel.sent.getLast();
+ Assertions.assertEquals(TOPIC, sent.topic());
+ channel.deliver(SESSION, sent);
+ Assertions.assertEquals(List.of(request), firstReceived.messages);
+ Assertions.assertEquals(List.of(request), secondReceived.messages);
+ second.send(request.response(null)).toCompletableFuture().join();
+ Assertions.assertEquals(firstReplyTopic, channel.sent.getLast().topic());
+ channel.deliver(SESSION, channel.sent.getLast());
+ Assertions.assertEquals(List.of(request, request.response(null)), firstReceived.messages);
+ Assertions.assertEquals(List.of(request), secondReceived.messages);
+ MessagingRequestReplyMessage otherRequest = request(secondId);
+ first.send(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER)).toCompletableFuture().join();
+ Assertions.assertEquals(secondReplyTopic, channel.sent.getLast().topic());
+ channel.deliver(SESSION, channel.sent.getLast());
+ Assertions.assertEquals(otherRequest.failure(MessagingRequestReplyMessage.Error.NO_HANDLER), secondReceived.messages.getLast());
+ }
+ Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active));
+ Assertions.assertTrue(channel.observers.isEmpty());
+ Assertions.assertFalse(channel.closed);
+ }
+
+ @Test
+ void keepsDerivedTopicsWithinRegistrationMetadataLimit() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ String topic = "x".repeat(256);
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, topic);
+ try (var subscription = adapter.subscribe(UUID.randomUUID(), new Received())) {
+ Assertions.assertEquals(topic, channel.subscriptions.get(1).topic);
+ Assertions.assertTrue(channel.subscriptions.getFirst().topic.length() <= 256);
+ }
+ Assertions.assertThrows(IllegalArgumentException.class, () -> DDRemoteDamRequestReplyTransport.forChannel(channel, " "));
+ Assertions.assertThrows(IllegalArgumentException.class, () -> DDRemoteDamRequestReplyTransport.forChannel(channel, topic + "x"));
+ }
+
+ @Test
+ void rollsBackReplySubscriptionAndStateObservationWhenRequestSubscriptionFails() {
+ FakeChannel channel = new FakeChannel();
+ channel.failTopic = TOPIC;
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class,
+ () -> adapter.subscribe(UUID.randomUUID(), new Received()));
+ Assertions.assertSame(channel.subscriptionFailure, failure.getCause());
+ Assertions.assertFalse(channel.subscriptions.getFirst().active);
+ Assertions.assertTrue(channel.observers.isEmpty());
+ Assertions.assertFalse(channel.closed);
+ Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received()));
+ assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(UUID.randomUUID())));
+ }
+
+ @Test
+ void scopedSubscriberPreservesCheckedOpeningFailureAndRollsBack() {
+ DDRemoteDamException unavailable = new DDRemoteDamException("Unavailable");
+ AtomicInteger closed = new AtomicInteger();
+ DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC,
+ message -> CompletableFuture.completedFuture(null), (topic, listener) -> {
+ if (topic.equals(TOPIC)) {
+ throw unavailable;
+ }
+ return closed::incrementAndGet;
+ });
+ MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class,
+ () -> adapter.subscribe(UUID.randomUUID(), new Received()));
+ Assertions.assertSame(unavailable, failure.getCause());
+ Assertions.assertEquals(1, closed.get());
+ }
+
+ @Test
+ void rejectsDisconnectedAndClosedChannelsAtOpening() {
+ for (DDRemoteDamChannelState state : List.of(
+ DDRemoteDamChannelState.DISCONNECTED, DDRemoteDamChannelState.CONNECTING, DDRemoteDamChannelState.CLOSED
+ )) {
+ FakeChannel channel = new FakeChannel();
+ channel.setStatus(new DDRemoteDamChannelStatus(state, null));
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class,
+ () -> adapter.subscribe(UUID.randomUUID(), new Received()));
+ Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason());
+ Assertions.assertTrue(channel.subscriptions.isEmpty());
+ Assertions.assertTrue(channel.observers.isEmpty());
+ }
+ }
+
+ @Test
+ void rollsBackAllResourcesAfterDisconnectDuringOpeningEvenIfTheChannelReconnects() {
+ FakeChannel channel = new FakeChannel();
+ channel.onSubscribe = topic -> {
+ if (TOPIC.equals(topic)) {
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null));
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION));
+ }
+ };
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ MessagingRequestReplyException failure = Assertions.assertThrows(MessagingRequestReplyException.class,
+ () -> adapter.subscribe(UUID.randomUUID(), new Received()));
+ Assertions.assertEquals(MessagingRequestReplyException.Reason.DISCONNECTED, failure.reason());
+ Assertions.assertEquals(2, channel.subscriptions.size());
+ Assertions.assertTrue(channel.subscriptions.stream().noneMatch(subscription -> subscription.active));
+ Assertions.assertTrue(channel.observers.isEmpty());
+ }
+
+ @Test
+ void propagatesDisconnectAndAcceptsOnlyTheCurrentPhysicalSource() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ adapter.send(request(endpoint)).toCompletableFuture().join();
+ DDRemoteDamMessage wire = channel.sent.getLast();
+ channel.deliver(new DDRemoteDamSessionInfo("other", "run", "socket"), wire);
+ channel.deliver(new DDRemoteDamSessionInfo("remoteDam", "other", "socket"), wire);
+ channel.deliver(new DDRemoteDamSessionInfo("remoteDam", "run", "other"), wire);
+ Assertions.assertTrue(received.messages.isEmpty());
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null));
+ Assertions.assertEquals(1, received.disconnects);
+ assertFailure(MessagingRequestReplyException.Reason.DISCONNECTED, adapter.send(request(endpoint)));
+ channel.deliver(SESSION, wire);
+ Assertions.assertTrue(received.messages.isEmpty());
+ DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "new-run", "new-socket");
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement));
+ channel.deliver(SESSION, wire);
+ channel.deliver(replacement, wire);
+ Assertions.assertEquals(1, received.messages.size());
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION));
+ Assertions.assertEquals(2, received.disconnects);
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CLOSED, null));
+ Assertions.assertEquals(3, received.disconnects);
+ }
+ }
+
+ @Test
+ void completesMessageDeliveryBeforeNotifyingAConcurrentSessionReplacement() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ CountDownLatch messageEntered = new CountDownLatch(1);
+ CountDownLatch releaseMessage = new CountDownLatch(1);
+ List delivered = new CopyOnWriteArrayList<>();
+ try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ messageEntered.countDown();
+ await(releaseMessage);
+ delivered.add("message");
+ }
+
+ @Override
+ public void onDisconnect() {
+ delivered.add("disconnect");
+ }
+ })) {
+ adapter.send(request(endpoint)).toCompletableFuture().join();
+ DDRemoteDamMessage wire = channel.sent.getLast();
+ FutureTask message = async(() -> channel.deliver(SESSION, wire));
+ try {
+ await(messageEntered);
+ CountDownLatch replacing = new CountDownLatch(1);
+ DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket");
+ FutureTask state = async(() -> {
+ replacing.countDown();
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement));
+ });
+ try {
+ await(replacing);
+ state.get(2, TimeUnit.SECONDS);
+ Assertions.assertTrue(delivered.isEmpty());
+ } finally {
+ releaseMessage.countDown();
+ }
+ message.get(2, TimeUnit.SECONDS);
+ state.get(2, TimeUnit.SECONDS);
+ Assertions.assertEquals(List.of("message", "disconnect"), delivered);
+
+ channel.deliver(SESSION, wire);
+ Assertions.assertEquals(List.of("message", "disconnect"), delivered);
+ } finally {
+ releaseMessage.countDown();
+ message.get(2, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void notifiesSessionReplacementBeforeDeliveringANewSourceMessage() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ CountDownLatch disconnectEntered = new CountDownLatch(1);
+ CountDownLatch releaseDisconnect = new CountDownLatch(1);
+ List delivered = new CopyOnWriteArrayList<>();
+ try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ delivered.add("message");
+ }
+
+ @Override
+ public void onDisconnect() {
+ disconnectEntered.countDown();
+ await(releaseDisconnect);
+ delivered.add("disconnect");
+ }
+ })) {
+ adapter.send(request(endpoint)).toCompletableFuture().join();
+ DDRemoteDamMessage wire = channel.sent.getLast();
+ DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket");
+ FutureTask state = async(() ->
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement)));
+ try {
+ await(disconnectEntered);
+ CountDownLatch receiving = new CountDownLatch(1);
+ FutureTask message = async(() -> {
+ receiving.countDown();
+ channel.deliver(replacement, wire);
+ });
+ try {
+ await(receiving);
+ message.get(2, TimeUnit.SECONDS);
+ Assertions.assertTrue(delivered.isEmpty());
+ } finally {
+ releaseDisconnect.countDown();
+ }
+ state.get(2, TimeUnit.SECONDS);
+ message.get(2, TimeUnit.SECONDS);
+ Assertions.assertEquals(List.of("disconnect", "message"), delivered);
+ } finally {
+ releaseDisconnect.countDown();
+ state.get(2, TimeUnit.SECONDS);
+ }
+ }
+ }
+
+ @Test
+ void defersReentrantStateNotificationsUntilTheCurrentMessageReturns() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ List delivered = new ArrayList<>();
+ try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null));
+ delivered.add("message");
+ }
+
+ @Override
+ public void onDisconnect() {
+ delivered.add("disconnect");
+ }
+ })) {
+ adapter.send(request(endpoint)).toCompletableFuture().join();
+
+ channel.deliver(SESSION, channel.sent.getLast());
+
+ Assertions.assertEquals(List.of("message", "disconnect"), delivered);
+ }
+ }
+
+ @Test
+ void establishesTheNewGenerationBeforeMessagesWhenStateObserversAreDelayed() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ List delivered = new ArrayList<>();
+ try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ delivered.add("message");
+ }
+
+ @Override
+ public void onDisconnect() {
+ delivered.add("disconnect");
+ }
+ })) {
+ adapter.send(request(endpoint)).toCompletableFuture().join();
+ DDRemoteDamMessage wire = channel.sent.getLast();
+ DDRemoteDamSessionInfo replacement = new DDRemoteDamSessionInfo("remoteDam", "next-run", "next-socket");
+ DDRemoteDamChannelStatus ready = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, replacement);
+ channel.status = ready;
+
+ channel.deliver(replacement, wire);
+
+ Assertions.assertEquals(List.of("disconnect", "message"), delivered);
+ for (Consumer observer : List.copyOf(channel.observers)) {
+ observer.accept(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null));
+ observer.accept(ready);
+ }
+ Assertions.assertEquals(List.of("disconnect", "message"), delivered);
+ channel.deliver(SESSION, wire);
+ Assertions.assertEquals(List.of("disconnect", "message"), delivered);
+ channel.deliver(replacement, wire);
+ Assertions.assertEquals(List.of("disconnect", "message", "message"), delivered);
+ }
+ }
+
+ @Test
+ void permitsConcurrentInlineRepliesWithoutHoldingCallbackLocks() throws Exception {
+ FakeChannel leftChannel = new FakeChannel();
+ FakeChannel rightChannel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport left = DDRemoteDamRequestReplyTransport.forChannel(leftChannel, TOPIC);
+ DDRemoteDamRequestReplyTransport right = DDRemoteDamRequestReplyTransport.forChannel(rightChannel, TOPIC);
+ UUID leftId = UUID.randomUUID();
+ UUID rightId = UUID.randomUUID();
+ CountDownLatch requests = new CountDownLatch(2);
+ AtomicInteger replies = new AtomicInteger();
+ try (var leftSubscription = left.subscribe(leftId, inlineReplies(left, requests, replies));
+ var rightSubscription = right.subscribe(rightId, inlineReplies(right, requests, replies))) {
+ left.send(request(leftId)).toCompletableFuture().join();
+ right.send(request(rightId)).toCompletableFuture().join();
+ DDRemoteDamMessage toRight = leftChannel.sent.getLast();
+ DDRemoteDamMessage toLeft = rightChannel.sent.getLast();
+ leftChannel.forward = message -> rightChannel.deliver(SESSION, message);
+ rightChannel.forward = message -> leftChannel.deliver(SESSION, message);
+
+ FutureTask leftDelivery = async(() -> leftChannel.deliver(SESSION, toLeft));
+ FutureTask rightDelivery = async(() -> rightChannel.deliver(SESSION, toRight));
+
+ leftDelivery.get(2, TimeUnit.SECONDS);
+ rightDelivery.get(2, TimeUnit.SECONDS);
+ Assertions.assertEquals(2, replies.get());
+ }
+ }
+
+ @Test
+ void boundsQueuedCallbacksAndPreservesDisconnectWhenTheQueueIsFull() throws Exception {
+ for (boolean disconnect : List.of(false, true)) {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicInteger messages = new AtomicInteger();
+ AtomicInteger disconnects = new AtomicInteger();
+ try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ entered.countDown();
+ await(release);
+ messages.incrementAndGet();
+ }
+
+ @Override
+ public void onDisconnect() {
+ disconnects.incrementAndGet();
+ }
+ })) {
+ adapter.send(request(endpoint)).toCompletableFuture().join();
+ DDRemoteDamMessage wire = channel.sent.getLast();
+ FutureTask delivery = async(() -> channel.deliver(SESSION, wire));
+ try {
+ await(entered);
+ for (int i = 0; i < 130; i++) {
+ channel.deliver(SESSION, wire);
+ }
+ if (disconnect) {
+ channel.setStatus(new DDRemoteDamChannelStatus(DDRemoteDamChannelState.DISCONNECTED, null));
+ }
+ } finally {
+ release.countDown();
+ delivery.get(2, TimeUnit.SECONDS);
+ }
+ Assertions.assertEquals(disconnect ? 1 : 129, messages.get());
+ Assertions.assertEquals(disconnect ? 1 : 0, disconnects.get());
+ }
+ }
+ }
+
+ @Test
+ void releasesReceivingResourcesWhenAListenerFails() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ IllegalStateException failure = new IllegalStateException("Listener failed");
+ try (var subscription = adapter.subscribe(endpoint, new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ throw failure;
+ }
+
+ @Override
+ public void onDisconnect() {
+ }
+ })) {
+ adapter.send(request(endpoint)).toCompletableFuture().join();
+
+ Assertions.assertSame(failure, Assertions.assertThrows(IllegalStateException.class,
+ () -> channel.deliver(SESSION, channel.sent.getLast())));
+
+ assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(endpoint)));
+ Assertions.assertTrue(channel.subscriptions.stream().noneMatch(value -> value.active));
+ Assertions.assertTrue(channel.observers.isEmpty());
+ Assertions.assertFalse(channel.closed);
+ }
+ }
+
+ @Test
+ void rejectsMalformedRecognizedFramesAndWrongTypeTopicAndOrigin() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ MessagingRequestReplyMessage request = request(endpoint);
+ adapter.send(request).toCompletableFuture().join();
+ DDRemoteDamMessage requestWire = channel.sent.getLast();
+ adapter.send(request.response(new JsonObject())).toCompletableFuture().join();
+ final DDRemoteDamMessage responseWire = channel.sent.getLast();
+ List malformed = new ArrayList<>();
+ JsonObject abbreviatedId = requestWire.payload();
+ abbreviatedId.addProperty("requestId", "1-1-1-1-1");
+ malformed.add(abbreviatedId);
+ JsonObject invalidOrigin = requestWire.payload();
+ invalidOrigin.addProperty("replyTo", "not-a-uuid");
+ malformed.add(invalidOrigin);
+ JsonObject numericId = requestWire.payload();
+ numericId.addProperty("requestId", 1);
+ malformed.add(numericId);
+ JsonObject unknownType = requestWire.payload();
+ unknownType.addProperty("type", "OTHER");
+ malformed.add(unknownType);
+ JsonObject missingData = requestWire.payload();
+ missingData.remove("data");
+ malformed.add(missingData);
+ JsonObject nullRequest = requestWire.payload();
+ nullRequest.add("data", JsonNull.INSTANCE);
+ malformed.add(nullRequest);
+ JsonObject primitiveData = requestWire.payload();
+ primitiveData.addProperty("data", "text");
+ malformed.add(primitiveData);
+ JsonObject requestError = requestWire.payload();
+ requestError.addProperty("error", "NO_HANDLER");
+ malformed.add(requestError);
+ for (JsonObject envelope : malformed) {
+ channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, requestWire.type(), envelope));
+ }
+ JsonObject responseError = responseWire.payload();
+ responseError.addProperty("error", "NO_HANDLER");
+ channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responseError));
+ JsonObject responsePrimitive = responseWire.payload();
+ responsePrimitive.addProperty("data", 42);
+ channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responsePrimitive));
+ JsonObject responseMissingData = responseWire.payload();
+ responseMissingData.remove("data");
+ channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), responseMissingData));
+ JsonObject wrongOrigin = responseWire.payload();
+ wrongOrigin.addProperty("replyTo", UUID.randomUUID().toString());
+ channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), responseWire.type(), wrongOrigin));
+ adapter.send(request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)).toCompletableFuture().join();
+ DDRemoteDamMessage errorWire = channel.sent.getLast();
+ JsonObject errorData = errorWire.payload();
+ errorData.add("data", new JsonObject());
+ channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), errorData));
+ JsonObject unknownError = errorWire.payload();
+ unknownError.addProperty("error", "OTHER");
+ channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), unknownError));
+ JsonObject missingError = errorWire.payload();
+ missingError.remove("error");
+ channel.deliver(SESSION, new DDRemoteDamMessage(errorWire.topic(), errorWire.type(), missingError));
+ channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, "unrelated", requestWire.payload()));
+ channel.deliver(SESSION, new DDRemoteDamMessage(TOPIC, responseWire.type(), responseWire.payload()));
+ channel.deliver(SESSION, new DDRemoteDamMessage(responseWire.topic(), requestWire.type(), requestWire.payload()));
+ channel.subscriptions.getFirst().listener.onMessage(SESSION, requestWire);
+ channel.subscriptions.get(1).listener.onMessage(SESSION, responseWire);
+ Assertions.assertTrue(received.messages.isEmpty());
+ channel.deliver(SESSION, requestWire);
+ channel.deliver(SESSION, responseWire);
+ channel.deliver(SESSION, errorWire);
+ Assertions.assertEquals(List.of(request, request.response(new JsonObject()),
+ request.failure(MessagingRequestReplyMessage.Error.HANDLER_FAILED)), received.messages);
+ }
+ }
+
+ @Test
+ void closeIsIdempotentAndLeavesBorrowedSubscriptionsIntact() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ final DDRemoteDamSubscription borrowed = channel.subscribe("other", (session, message) -> { });
+ DDRemoteDamRequestReplyTransport adapter = DDRemoteDamRequestReplyTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ final MessagingSubscription subscription = adapter.subscribe(endpoint, new Received());
+ Assertions.assertThrows(IllegalStateException.class, () -> adapter.subscribe(UUID.randomUUID(), new Received()));
+ Assertions.assertThrows(CompletionException.class,
+ () -> adapter.send(request(UUID.randomUUID())).toCompletableFuture().join());
+ adapter.close();
+ subscription.close();
+ assertFailure(MessagingRequestReplyException.Reason.CLOSED, adapter.send(request(endpoint)));
+ Assertions.assertTrue(channel.subscriptions.getFirst().active);
+ Assertions.assertFalse(channel.closed);
+ borrowed.close();
+ }
+
+ @Test
+ void boundsOutstandingSendsAndReleasesBudgetOnCompletionAndFailure() throws Exception {
+ List> pending = new ArrayList<>();
+ DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, message -> {
+ CompletableFuture result = new CompletableFuture<>();
+ pending.add(result);
+ return result;
+ }, (topic, listener) -> () -> { });
+ UUID endpoint = UUID.randomUUID();
+ try (var subscription = adapter.subscribe(endpoint, new Received())) {
+ List> sends = new ArrayList<>();
+ for (int i = 0; i < 128; i++) {
+ sends.add(adapter.send(request(endpoint)));
+ }
+ assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint)));
+ Assertions.assertEquals(128, pending.size());
+ pending.getFirst().complete(null);
+ sends.getFirst().toCompletableFuture().get(2, TimeUnit.SECONDS);
+ adapter.send(request(endpoint));
+ Assertions.assertEquals(129, pending.size());
+ assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint)));
+ IllegalStateException unavailable = new IllegalStateException("Unavailable");
+ pending.get(1).completeExceptionally(unavailable);
+ ExecutionException failure = Assertions.assertThrows(ExecutionException.class,
+ () -> sends.get(1).toCompletableFuture().get(2, TimeUnit.SECONDS));
+ Assertions.assertSame(unavailable, failure.getCause());
+ adapter.send(request(endpoint));
+ Assertions.assertEquals(130, pending.size());
+ assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint)));
+ Assertions.assertTrue(sends.get(2).toCompletableFuture().cancel(false));
+ assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint)));
+ pending.get(2).complete(null);
+ adapter.send(request(endpoint));
+ Assertions.assertEquals(131, pending.size());
+ assertFailure(MessagingRequestReplyException.Reason.OVERLOADED, adapter.send(request(endpoint)));
+ } finally {
+ pending.forEach(result -> result.complete(null));
+ }
+ }
+
+ @Test
+ void releasesSendBudgetAfterSynchronousSenderFailure() throws Exception {
+ IllegalStateException unavailable = new IllegalStateException("Unavailable");
+ AtomicInteger attempts = new AtomicInteger();
+ DDRemoteDamRequestReplyTransport adapter = new DDRemoteDamRequestReplyTransport(TOPIC, message -> {
+ if (attempts.incrementAndGet() <= 129) {
+ throw unavailable;
+ }
+ return CompletableFuture.completedFuture(null);
+ }, (topic, listener) -> () -> { });
+ UUID endpoint = UUID.randomUUID();
+ try (var subscription = adapter.subscribe(endpoint, new Received())) {
+ for (int i = 0; i < 129; i++) {
+ ExecutionException failure = Assertions.assertThrows(ExecutionException.class,
+ () -> adapter.send(request(endpoint)).toCompletableFuture().get(2, TimeUnit.SECONDS));
+ Assertions.assertSame(unavailable, failure.getCause());
+ }
+ adapter.send(request(endpoint)).toCompletableFuture().get(2, TimeUnit.SECONDS);
+ Assertions.assertEquals(130, attempts.get());
+ }
+ }
+
+ @NotNull
+ private static MessagingRequestReplyMessage request(@NotNull UUID endpoint) {
+ return MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, new JsonObject());
+ }
+
+ private static void assertFailure(@NotNull MessagingRequestReplyException.Reason reason, @NotNull CompletionStage result) {
+ ExecutionException failure = Assertions.assertThrows(ExecutionException.class,
+ () -> result.toCompletableFuture().get(2, TimeUnit.SECONDS));
+ Assertions.assertEquals(reason, Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause()).reason());
+ }
+
+ @NotNull
+ private static FutureTask async(@NotNull Runnable action) {
+ FutureTask task = new FutureTask<>(action, null);
+ Thread.ofPlatform().daemon().start(task);
+ return task;
+ }
+
+ @NotNull
+ private static MessagingRequestReplyTransport.Listener inlineReplies(
+ @NotNull DDRemoteDamRequestReplyTransport adapter,
+ @NotNull CountDownLatch requests,
+ @NotNull AtomicInteger replies
+ ) {
+ return new MessagingRequestReplyTransport.Listener<>() {
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ if (message.type() == MessagingRequestReplyMessage.Type.REQUEST) {
+ requests.countDown();
+ await(requests);
+ adapter.send(message.response(null)).toCompletableFuture().join();
+ } else {
+ replies.incrementAndGet();
+ }
+ }
+
+ @Override
+ public void onDisconnect() {
+ }
+ };
+ }
+
+ private static void await(@NotNull CountDownLatch latch) {
+ try {
+ Assertions.assertTrue(latch.await(2, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError(e);
+ }
+ }
+
+ private static final class Received implements MessagingRequestReplyTransport.Listener {
+ private final List> messages = new ArrayList<>();
+ private int disconnects;
+
+ @Override
+ public void onMessage(@NotNull MessagingRequestReplyMessage message) {
+ messages.add(message);
+ }
+
+ @Override
+ public void onDisconnect() {
+ disconnects++;
+ }
+ }
+
+ private static final class FakeChannel implements DDRemoteDamChannel {
+ private final List subscriptions = new ArrayList<>();
+ private final List> observers = new ArrayList<>();
+ private final List sent = new ArrayList<>();
+ private final RuntimeException subscriptionFailure = new IllegalStateException("Subscription unavailable");
+ private volatile DDRemoteDamChannelStatus status = new DDRemoteDamChannelStatus(DDRemoteDamChannelState.CONNECTED, SESSION);
+ @Nullable
+ private String failTopic;
+ @Nullable
+ private Consumer onSubscribe;
+ @Nullable
+ private Consumer forward;
+ private boolean closed;
+
+ @Override
+ @NotNull
+ public DDRemoteDamChannelStatus getStatus() {
+ return status;
+ }
+
+ @Override
+ @NotNull
+ public DDRemoteDamSubscription observeState(@NotNull Consumer observer) {
+ observers.add(observer);
+ observer.accept(status);
+ return () -> observers.remove(observer);
+ }
+
+ @Override
+ @NotNull
+ public CompletionStage send(@NotNull DDRemoteDamMessage message) {
+ sent.add(message);
+ if (forward != null) {
+ forward.accept(message);
+ }
+ return CompletableFuture.completedFuture(null);
+ }
+
+ @Override
+ @NotNull
+ public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) {
+ if (topic.equals(failTopic)) {
+ throw subscriptionFailure;
+ }
+ TopicSubscription subscription = new TopicSubscription(topic, listener);
+ subscriptions.add(subscription);
+ if (onSubscribe != null) {
+ onSubscribe.accept(topic);
+ }
+ return () -> subscription.active = false;
+ }
+
+ @Override
+ @NotNull
+ public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) {
+ throw new UnsupportedOperationException();
+ }
+
+ private void setStatus(@NotNull DDRemoteDamChannelStatus next) {
+ status = next;
+ List.copyOf(observers).forEach(observer -> observer.accept(next));
+ }
+
+ private void deliver(@NotNull DDRemoteDamSessionInfo source, @NotNull DDRemoteDamMessage message) {
+ for (TopicSubscription subscription : List.copyOf(subscriptions)) {
+ if (subscription.active && subscription.topic.equals(message.topic())) {
+ subscription.listener.onMessage(source, message);
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ }
+ }
+
+ private static final class TopicSubscription {
+ private final String topic;
+ private final DDRemoteDamMessageListener listener;
+ private boolean active = true;
+
+ private TopicSubscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) {
+ this.topic = topic;
+ this.listener = listener;
+ }
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java
new file mode 100644
index 0000000..b7d87d2
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamGsonRpcCodecTest.java
@@ -0,0 +1,83 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.rpc;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.reflect.TypeToken;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Type;
+import java.util.List;
+
+class DDRemoteDamGsonRpcCodecTest {
+ private final DDRemoteDamGsonRpcCodec codec = new DDRemoteDamGsonRpcCodec(new Gson());
+
+ @Test
+ void encodesOrdinaryAndPrimitiveValues() {
+ Assertions.assertEquals(new JsonPrimitive("Hello"), codec.encode("Hello", String.class));
+ Assertions.assertEquals("Hello", codec.decode(new JsonPrimitive("Hello"), String.class));
+ Assertions.assertEquals(new JsonPrimitive(2), codec.encode(2, int.class));
+ Assertions.assertEquals(2, codec.decode(new JsonPrimitive(2), int.class));
+ }
+
+ @Test
+ void normalizesJsonNullAndSupportsNullableValues() {
+ Assertions.assertNull(codec.encode(null, String.class));
+ Assertions.assertNull(codec.encode(JsonNull.INSTANCE, JsonElement.class));
+ Assertions.assertNull(codec.decode(null, String.class));
+ Assertions.assertNull(codec.decode(JsonNull.INSTANCE, String.class));
+ }
+
+ @Test
+ void usesDeclaredGenericTypesForApplicationRecords() {
+ Type type = new TypeToken>() { }.getType();
+ List- values = List.of(new Item("Hello", List.of(1, 2)));
+
+ Object decoded = codec.decode(codec.encode(values, type), type);
+
+ Assertions.assertEquals(values, decoded);
+ Assertions.assertInstanceOf(Item.class, ((List>) decoded).getFirst());
+ }
+
+ @Test
+ void preservesApplicationGsonConfiguration() {
+ var configured = new DDRemoteDamGsonRpcCodec(new GsonBuilder()
+ .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create());
+ Item value = new Item("configured", List.of(3));
+
+ JsonElement encoded = configured.encode(value, Item.class);
+
+ Assertions.assertEquals("configured", encoded.getAsJsonObject().get("message_text").getAsString());
+ Assertions.assertEquals(value, configured.decode(encoded, Item.class));
+ }
+
+ @Test
+ void requiresAnExplicitGsonAndDeclaredTypes() {
+ Assertions.assertThrows(NullPointerException.class, () -> new DDRemoteDamGsonRpcCodec(null));
+ Assertions.assertThrows(NullPointerException.class, () -> codec.encode("Hello", null));
+ Assertions.assertThrows(NullPointerException.class, () -> codec.decode(null, null));
+ }
+
+ private record Item(String messageText, List numbers) {
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java
new file mode 100644
index 0000000..32323ff
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTopicsIntegrationTest.java
@@ -0,0 +1,194 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.rpc;
+
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions;
+import com.dbeaver.messaging.rpc.MessagingRpcCodec;
+import com.dbeaver.messaging.rpc.MessagingRpcEndpoint;
+import com.dbeaver.messaging.rpc.MessagingRpcException;
+import com.dbeaver.messaging.rpc.MessagingRpcMessage;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonPrimitive;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.lang.reflect.Type;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Consumer;
+
+@Timeout(10)
+class DDRemoteDamRpcTopicsIntegrationTest {
+ private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "physical-session");
+ private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8);
+
+ @Test
+ void routesExactDeveloperTopicsBidirectionallyWithIndependentBindingCodecs() {
+ Channel remoteDam = new Channel();
+ Channel firstBackend = new Channel();
+ Channel secondBackend = new Channel();
+ remoteDam.forward = message -> {
+ firstBackend.receive(message);
+ secondBackend.receive(message);
+ };
+ firstBackend.forward = remoteDam::receive;
+ secondBackend.forward = remoteDam::receive;
+ var codec = new DDRemoteDamGsonRpcCodec(new Gson());
+ String firstTopic = "hello.files.v2";
+ String secondTopic = "developer..chosen-topic";
+ var serviceCodec = new PrefixCodec("response:", "argument:");
+ var firstProxyCodec = new PrefixCodec("first:", "one:");
+ var secondProxyCodec = new PrefixCodec("second:", "two:");
+ try (var remoteDamRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(remoteDam), codec, OPTIONS);
+ var firstRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(firstBackend), OPTIONS);
+ var secondRpc = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(secondBackend), OPTIONS);
+ var firstGreeting = firstRpc.register(firstTopic, Greeting.class, value -> value, serviceCodec);
+ var secondGreeting = secondRpc.register(secondTopic, Greeting.class, value -> "other:" + value, codec);
+ var reverseGreeting = remoteDamRpc.register(firstTopic, Greeting.class, value -> "remoteDam:" + value)) {
+ Greeting first = remoteDamRpc.proxy(firstTopic, Greeting.class, firstProxyCodec);
+ Greeting second = remoteDamRpc.proxy(firstTopic, Greeting.class, secondProxyCodec);
+ Assertions.assertEquals("one:response:argument:first:Hello", first.hello("Hello"));
+ Assertions.assertEquals("two:response:argument:second:Hello", second.hello("Hello"));
+ Assertions.assertEquals("other:Hello", remoteDamRpc.proxy(secondTopic, Greeting.class).hello("Hello"));
+ Assertions.assertEquals("remoteDam:reverse", firstRpc.proxy(firstTopic, Greeting.class, codec).hello("reverse"));
+ Assertions.assertEquals("response:argument:fallback", remoteDamRpc.proxy(firstTopic, Greeting.class).hello("fallback"));
+ Assertions.assertEquals(4, remoteDam.subscriptions.size());
+ Assertions.assertEquals(2, firstBackend.subscriptions.size());
+ Assertions.assertEquals(2, secondBackend.subscriptions.size());
+ Assertions.assertTrue(remoteDam.subscriptions.stream().anyMatch(value -> value.topic().equals(firstTopic)));
+ Assertions.assertTrue(remoteDam.subscriptions.stream().anyMatch(value -> value.topic().equals(secondTopic)));
+ for (DDRemoteDamMessage message : remoteDam.sent) {
+ if ("REQUEST".equals(message.payload().get("type").getAsString())) {
+ Assertions.assertTrue(List.of(firstTopic, secondTopic).contains(message.topic()));
+ JsonElement envelope = message.payload().get("data");
+ Assertions.assertFalse(envelope.getAsJsonObject().has("topic"));
+ Assertions.assertEquals("dbeaver.rpc.v2", envelope.getAsJsonObject().get("protocol").getAsString());
+ Assertions.assertEquals(1, envelope.getAsJsonObject().getAsJsonArray("data").size());
+ }
+ }
+ firstGreeting.close();
+ MessagingRpcException missing = Assertions.assertThrows(MessagingRpcException.class,
+ () -> first.hello("closed"));
+ Assertions.assertEquals(MessagingRpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError());
+ Assertions.assertEquals("other:still", remoteDamRpc.proxy(secondTopic, Greeting.class).hello("still"));
+ }
+ for (Channel channel : List.of(remoteDam, firstBackend, secondBackend)) {
+ Assertions.assertFalse(channel.closed);
+ Assertions.assertTrue(channel.subscriptions.isEmpty());
+ Assertions.assertTrue(channel.observers.isEmpty());
+ }
+ }
+
+ /** Answers a greeting on the selected backend. */
+ public interface Greeting {
+ @NotNull
+ String hello(@NotNull String value);
+ }
+
+ private record PrefixCodec(@NotNull String encodedPrefix, @NotNull String decodedPrefix) implements MessagingRpcCodec {
+ @Override
+ @NotNull
+ public JsonElement encode(@Nullable Object value, @NotNull Type type) {
+ return new JsonPrimitive(encodedPrefix + value);
+ }
+
+ @Override
+ @NotNull
+ public Object decode(@Nullable JsonElement payload, @NotNull Type type) {
+ return decodedPrefix + payload.getAsString();
+ }
+ }
+
+ private static final class Channel implements DDRemoteDamChannel {
+ private final List subscriptions = new CopyOnWriteArrayList<>();
+ private final List> observers = new CopyOnWriteArrayList<>();
+ private final List sent = new CopyOnWriteArrayList<>();
+ private Consumer forward = message -> { };
+ private boolean closed;
+
+ @Override
+ @NotNull
+ public DDRemoteDamChannelStatus getStatus() {
+ return new DDRemoteDamChannelStatus(
+ closed ? DDRemoteDamChannelState.CLOSED : DDRemoteDamChannelState.CONNECTED, closed ? null : SESSION);
+ }
+
+ @Override
+ @NotNull
+ public DDRemoteDamSubscription observeState(@NotNull Consumer observer) {
+ observers.add(observer);
+ observer.accept(getStatus());
+ return () -> observers.remove(observer);
+ }
+
+ @Override
+ @NotNull
+ public CompletionStage send(@NotNull DDRemoteDamMessage message) {
+ sent.add(message);
+ forward.accept(message);
+ return CompletableFuture.completedFuture(null);
+ }
+
+ private void receive(@NotNull DDRemoteDamMessage message) {
+ for (Subscription subscription : subscriptions) {
+ if (subscription.topic.equals(message.topic())) {
+ subscription.listener.onMessage(SESSION, message);
+ }
+ }
+ }
+
+ @Override
+ @NotNull
+ public DDRemoteDamSubscription subscribe(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) {
+ var subscription = new Subscription(topic, listener);
+ subscriptions.add(subscription);
+ return () -> subscriptions.remove(subscription);
+ }
+
+ @Override
+ @NotNull
+ public DDRemoteDamSubscription addSessionListener(@NotNull DDRemoteDamSessionListener listener) {
+ return () -> { };
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ observers.forEach(observer -> observer.accept(getStatus()));
+ subscriptions.clear();
+ observers.clear();
+ }
+ }
+
+ private record Subscription(@NotNull String topic, @NotNull DDRemoteDamMessageListener listener) {
+ }
+}
diff --git a/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java
new file mode 100644
index 0000000..8eb610c
--- /dev/null
+++ b/apis/com.dbeaver.datadam.dam.remote.api/src/test/java/com/dbeaver/datadam/dam/remote/api/rpc/DDRemoteDamRpcTransportTest.java
@@ -0,0 +1,1648 @@
+/*
+ * DBeaver - Universal Database Manager
+ * Copyright (C) 2010-2026 DBeaver Corp
+ *
+ * All Rights Reserved.
+ *
+ * NOTICE: All information contained herein is, and remains
+ * the property of DBeaver Corp and its suppliers, if any.
+ * The intellectual and technical concepts contained
+ * herein are proprietary to DBeaver Corp and its suppliers
+ * and may be covered by U.S. and Foreign Patents,
+ * patents in process, and are protected by trade secret or copyright law.
+ * Dissemination of this information or reproduction of this material
+ * is strictly forbidden unless prior written permission is obtained
+ * from DBeaver Corp.
+ */
+package com.dbeaver.datadam.dam.remote.api.rpc;
+
+import com.dbeaver.datadam.dam.remote.api.DDRemoteDamProtocol;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamMessage;
+import com.dbeaver.datadam.dam.remote.api.model.DDRemoteDamSessionInfo;
+import com.dbeaver.datadam.dam.remote.api.requestreply.DDRemoteDamRequestReplyTransport;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannel;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelState;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamChannelStatus;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamMessageListener;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSessionListener;
+import com.dbeaver.datadam.dam.remote.api.transport.DDRemoteDamSubscription;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyEndpoint;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyException;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyMessage;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyOptions;
+import com.dbeaver.messaging.requestreply.MessagingRequestReplyTransport;
+import com.dbeaver.messaging.requestreply.MessagingSubscription;
+import com.dbeaver.messaging.rpc.MessagingRpcCodec;
+import com.dbeaver.messaging.rpc.MessagingRpcEndpoint;
+import com.dbeaver.messaging.rpc.MessagingRpcException;
+import com.dbeaver.messaging.rpc.MessagingRpcExceptionDetails;
+import com.dbeaver.messaging.rpc.MessagingRpcMessage;
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonNull;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSyntaxException;
+import org.jkiss.code.NotNull;
+import org.jkiss.code.Nullable;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+
+@Timeout(10)
+class DDRemoteDamRpcTransportTest {
+ private static final String TOPIC = "custom.files";
+ private static final DDRemoteDamSessionInfo SESSION = new DDRemoteDamSessionInfo("remoteDam", "run", "socket");
+ private static final MessagingRequestReplyOptions OPTIONS = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 16, 8);
+ private static final MessagingRpcCodec CODEC = new GsonCodec();
+
+ @Test
+ void delegatesRequestExecutionUnlessAnExplicitExecutorIsSupplied() {
+ MemoryTransport delegate = new MemoryTransport();
+ List queued = new ArrayList<>();
+ AtomicInteger calls = new AtomicInteger();
+ delegate.requestExecutor = queued::add;
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ adapter.executeRequest(calls::incrementAndGet);
+ Assertions.assertEquals(1, queued.size());
+ Assertions.assertEquals(0, calls.get());
+ queued.removeFirst().run();
+ Assertions.assertEquals(1, calls.get());
+
+ RejectedExecutionException unavailable = new RejectedExecutionException("Request execution unavailable");
+ delegate.requestExecutor = request -> {
+ throw unavailable;
+ };
+ Assertions.assertSame(unavailable,
+ Assertions.assertThrows(RejectedExecutionException.class, () -> adapter.executeRequest(calls::incrementAndGet)));
+
+ DDRemoteDamRpcTransport inline = new DDRemoteDamRpcTransport(delegate, Runnable::run);
+ inline.executeRequest(calls::incrementAndGet);
+ Assertions.assertEquals(2, calls.get());
+ }
+
+ @Test
+ void channelRequestsRunInlineByDefaultAndCanUseABorrowedExecutor() throws Exception {
+ try (var executor = Executors.newSingleThreadExecutor()) {
+ for (boolean supplied : List.of(false, true)) {
+ FakeChannel channel = new FakeChannel();
+ channel.forward = channel::deliver;
+ AtomicReference handlerThread = new AtomicReference<>();
+ try (var endpoint = MessagingRpcEndpoint.open(topic -> supplied
+ ? DDRemoteDamRpcTransport.forChannel(channel, topic, executor)
+ : DDRemoteDamRpcTransport.forChannel(channel, topic), CODEC, OPTIONS);
+ var service = endpoint.register(TOPIC, Text.class, value -> {
+ handlerThread.set(Thread.currentThread());
+ return "reply:" + value;
+ })) {
+ Assertions.assertEquals("reply:value", endpoint.proxy(TOPIC, Text.class).apply("value"));
+ if (supplied) {
+ Assertions.assertNotEquals(Thread.currentThread(), handlerThread.get());
+ } else {
+ Assertions.assertSame(Thread.currentThread(), handlerThread.get());
+ }
+ }
+ Assertions.assertFalse(executor.isShutdown());
+ Assertions.assertEquals("still available", executor.submit(() -> "still available").get(2, TimeUnit.SECONDS));
+ Assertions.assertFalse(channel.closed);
+ }
+ }
+ }
+
+ @Test
+ void mapsSuppliedExecutorRejectionToRemoteOverloadAndAllowsFreshCalls() {
+ FakeChannel channel = new FakeChannel();
+ channel.forward = channel::deliver;
+ AtomicBoolean rejecting = new AtomicBoolean(true);
+ AtomicInteger calls = new AtomicInteger();
+ Executor executor = request -> {
+ if (rejecting.get()) {
+ throw new RejectedExecutionException("Request execution unavailable");
+ }
+ request.run();
+ };
+ var options = new MessagingRequestReplyOptions(Duration.ofSeconds(2), 1, 1);
+ try (var endpoint = MessagingRpcEndpoint.open(
+ topic -> DDRemoteDamRpcTransport.forChannel(channel, topic, executor), CODEC, options);
+ var service = endpoint.register(TOPIC, Text.class, value -> {
+ calls.incrementAndGet();
+ return value;
+ })) {
+ Text proxy = endpoint.proxy(TOPIC, Text.class);
+ MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("rejected"));
+ Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason());
+ Assertions.assertNull(failure.remoteError());
+ MessagingRequestReplyException outer = Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause());
+ Assertions.assertEquals(MessagingRequestReplyMessage.Error.OVERLOADED, outer.remoteError());
+ Assertions.assertEquals(0, calls.get());
+
+ rejecting.set(false);
+ Assertions.assertEquals("recovered", proxy.apply("recovered"));
+ Assertions.assertEquals(1, calls.get());
+ }
+ }
+
+ @Test
+ void supportsSynchronousCallbacksWithBorrowedRequestExecutorsAndIndependentChannelDelivery() throws Exception {
+ try (var deliveries = Executors.newCachedThreadPool();
+ var requests = Executors.newCachedThreadPool()) {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = message -> deliveries.execute(() -> right.deliver(message));
+ right.forward = message -> deliveries.execute(() -> left.deliver(message));
+ try (var caller = MessagingRpcEndpoint.open(
+ topic -> DDRemoteDamRpcTransport.forChannel(left, topic, requests), CODEC, OPTIONS);
+ var handler = MessagingRpcEndpoint.open(
+ topic -> DDRemoteDamRpcTransport.forChannel(right, topic, requests), CODEC, OPTIONS);
+ var callback = caller.register(TOPIC, Text.class, value -> "callback:" + value);
+ var service = handler.register(TOPIC, Text.class, value -> handler.proxy(TOPIC, Text.class).apply(value))) {
+ Assertions.assertEquals("callback:value", caller.proxy(TOPIC, Text.class).apply("value"));
+ }
+ Assertions.assertFalse(requests.isShutdown());
+ Assertions.assertFalse(deliveries.isShutdown());
+ Assertions.assertFalse(left.closed);
+ Assertions.assertFalse(right.closed);
+ }
+ }
+
+ @Test
+ void servesIndependentBidirectionalTopicsWithoutClosingTheBorrowedChannel() throws Exception {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = right::deliver;
+ right.forward = left::deliver;
+ AtomicInteger nullArguments = new AtomicInteger();
+ AtomicInteger notifications = new AtomicInteger();
+ final DDRemoteDamSubscription borrowed = left.subscribe("other", (session, message) -> { });
+ try (var caller = MessagingRpcEndpoint.open(
+ topic -> new DDRemoteDamRpcTransport(DDRemoteDamRequestReplyTransport.forChannel(left, topic)), CODEC, OPTIONS);
+ var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS);
+ var leftNames = caller.register("names", Names.class, values -> List.of("left", values.getFirst()));
+ var rightNames = handler.register("names", Names.class, values -> List.of("right", values.getFirst()));
+ var otherNames = handler.register("other-names", Names.class, values -> List.of("other"));
+ var records = handler.register("records", Records.class, value -> new Item(value.name(), List.of("record")));
+ var nullable = handler.register("nullable", Text.class, value -> {
+ if (value == null) {
+ nullArguments.incrementAndGet();
+ }
+ return null;
+ });
+ var notification = handler.register("notifications", Notification.class, value -> notifications.incrementAndGet())) {
+ Assertions.assertEquals(List.of("right", "request"), caller.proxy("names", Names.class).apply(List.of("request")));
+ Assertions.assertEquals(List.of("left", "reverse"), handler.proxy("names", Names.class).apply(List.of("reverse")));
+ Assertions.assertEquals(List.of("other"), caller.proxy("other-names", Names.class).apply(List.of()));
+ Assertions.assertEquals(new Item("name", List.of("record")),
+ caller.proxy("records", Records.class).apply(new Item("name", List.of("argument"))));
+ Assertions.assertNull(caller.proxy("nullable", Text.class).apply(null));
+ Assertions.assertEquals(1, nullArguments.get());
+ caller.proxy("notifications", Notification.class).accept(null);
+ Assertions.assertEquals(1, notifications.get());
+ Assertions.assertEquals(11, left.subscriptions.size());
+ Assertions.assertEquals(10, right.subscriptions.size());
+ Assertions.assertEquals("notifications", left.subscriptions.getLast().topic);
+ Assertions.assertEquals("notifications", right.subscriptions.getLast().topic);
+ otherNames.close();
+ MessagingRpcException missing = Assertions.assertThrows(MessagingRpcException.class,
+ () -> caller.proxy("other-names", Names.class).apply(List.of()));
+ Assertions.assertEquals(MessagingRpcMessage.Error.UNKNOWN_SERVICE, missing.remoteError());
+ Assertions.assertEquals(List.of("right", "still-open"),
+ caller.proxy("names", Names.class).apply(List.of("still-open")));
+ }
+ Assertions.assertFalse(left.closed);
+ Assertions.assertFalse(right.closed);
+ Assertions.assertTrue(left.observers.isEmpty());
+ Assertions.assertTrue(right.observers.isEmpty());
+ Assertions.assertTrue(left.subscriptions.getFirst().active);
+ Assertions.assertEquals(1, left.subscriptions.stream().filter(subscription -> subscription.active).count());
+ Assertions.assertTrue(right.subscriptions.stream().noneMatch(subscription -> subscription.active));
+ borrowed.close();
+ }
+
+ @Test
+ void isolatesGsonArgumentsAndResultsAcrossRealMessageChannels() throws Exception {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = right::deliver;
+ right.forward = left::deliver;
+ AtomicReference
- received = new AtomicReference<>();
+ Item argument = new Item("argument", new ArrayList<>(List.of("initial")));
+ try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), CODEC, OPTIONS);
+ var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS);
+ var service = handler.register("records", Records.class, value -> {
+ received.set(value);
+ value.tags().add("handler");
+ return value;
+ })) {
+ Item result = caller.proxy("records", Records.class).apply(argument);
+ Assertions.assertEquals(List.of("initial"), argument.tags());
+ Assertions.assertEquals(List.of("initial", "handler"), result.tags());
+ argument.tags().add("caller");
+ Assertions.assertEquals(List.of("initial", "handler"), received.get().tags());
+ received.get().tags().add("later");
+ Assertions.assertEquals(List.of("initial", "handler"), result.tags());
+ result.tags().add("result");
+ Assertions.assertEquals(List.of("initial", "handler", "later"), received.get().tags());
+ }
+ }
+
+ @Test
+ void roundTripsZeroArgumentsAndOverloadsUsingEveryParameterAcrossJsonFraming() {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ var codec = new DDRemoteDamGsonRpcCodec(new Gson());
+ Overloaded implementation = new Overloaded() {
+ @Override
+ @NotNull
+ public String apply() {
+ return "zero";
+ }
+
+ @Override
+ @NotNull
+ public String apply(@Nullable String first) {
+ return "one:" + first;
+ }
+
+ @Override
+ @NotNull
+ public String apply(@Nullable String first, @Nullable String second) {
+ return "two:" + first + ":" + second;
+ }
+
+ @Override
+ @NotNull
+ public String apply(@Nullable String first, int second) {
+ return "number:" + first + ":" + second;
+ }
+
+ @Override
+ @NotNull
+ public String apply(@Nullable String first, @Nullable String second, @Nullable String third) {
+ return "three:" + first + ":" + second + ":" + third;
+ }
+ };
+ try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), codec, OPTIONS);
+ var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS);
+ var service = handler.register(TOPIC, Overloaded.class, implementation)) {
+ Overloaded proxy = caller.proxy(TOPIC, Overloaded.class);
+ Assertions.assertEquals("zero", proxy.apply());
+ Assertions.assertEquals("one:first", proxy.apply("first"));
+ Assertions.assertEquals("two:first:second", proxy.apply("first", "second"));
+ Assertions.assertEquals("number:first:2", proxy.apply("first", 2));
+ Assertions.assertEquals("three:first:null:third", proxy.apply("first", null, "third"));
+ Assertions.assertEquals("one:null", proxy.apply(null));
+ Assertions.assertEquals("two:null:null", proxy.apply(null, null));
+ List expectedData = List.of("[]", "[\"first\"]", "[\"first\",\"second\"]", "[\"first\",2]",
+ "[\"first\",null,\"third\"]", "[null]", "[null,null]");
+ String text = String.class.getTypeName();
+ List expectedOperations = List.of("apply()", "apply(" + text + ")", "apply(" + text + "," + text + ")",
+ "apply(" + text + ",int)", "apply(" + text + "," + text + "," + text + ")", "apply(" + text + ")",
+ "apply(" + text + "," + text + ")");
+ Assertions.assertEquals(expectedData.size(), left.sent.size());
+ Assertions.assertEquals(expectedData.size(), right.sent.size());
+ for (int i = 0; i < expectedData.size(); i++) {
+ JsonObject request = left.sent.get(i).payload().getAsJsonObject("data");
+ Assertions.assertEquals(Set.of("protocol", "type", "operation", "data"), request.keySet());
+ Assertions.assertEquals(expectedOperations.get(i), request.get("operation").getAsString());
+ Assertions.assertEquals(JsonParser.parseString(expectedData.get(i)), request.get("data"));
+ JsonObject response = right.sent.get(i).payload().getAsJsonObject("data");
+ Assertions.assertEquals(Set.of("protocol", "type", "data"), response.keySet());
+ Assertions.assertEquals("RESPONSE", response.get("type").getAsString());
+ Assertions.assertTrue(response.get("data").isJsonPrimitive());
+ }
+ }
+ }
+
+ @Test
+ void roundTripsVarargsAsOneArrayArgumentAcrossJsonFraming() {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ var codec = new DDRemoteDamGsonRpcCodec(new Gson());
+ try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), codec, OPTIONS);
+ var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS);
+ var service = handler.register(TOPIC, Varargs.class,
+ (prefix, values) -> prefix + ":" + (values == null ? "null" : Arrays.toString(values)))) {
+ Varargs proxy = caller.proxy(TOPIC, Varargs.class);
+ Assertions.assertEquals("empty:[]", proxy.apply("empty"));
+ Assertions.assertEquals("many:[first, null, third]", proxy.apply("many", "first", null, "third"));
+ Assertions.assertEquals("null:null", proxy.apply("null", (String[]) null));
+ List expectedData = List.of("[\"empty\",[]]", "[\"many\",[\"first\",null,\"third\"]]", "[\"null\",null]");
+ Assertions.assertEquals(expectedData.size(), left.sent.size());
+ for (int i = 0; i < expectedData.size(); i++) {
+ JsonObject request = left.sent.get(i).payload().getAsJsonObject("data");
+ Assertions.assertEquals("apply(" + String.class.getTypeName() + "," + String[].class.getTypeName() + ")",
+ request.get("operation").getAsString());
+ Assertions.assertEquals(JsonParser.parseString(expectedData.get(i)), request.get("data"));
+ Assertions.assertEquals(2, request.getAsJsonArray("data").size());
+ }
+ }
+ }
+
+ @Test
+ void roundTripsEachArgumentsDeclaredRecordAndGenericTypesAcrossJsonFraming() throws Exception {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ CountingCodec callerCodec = new CountingCodec(new DDRemoteDamGsonRpcCodec(new Gson()));
+ CountingCodec handlerCodec = new CountingCodec(new DDRemoteDamGsonRpcCodec(new Gson()));
+ Item selected = new Item("selected", List.of("chosen"));
+ List
- items = List.of(new Item("nested", List.of("tag")));
+ List numbers = List.of(7, 8);
+ try (var caller = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(left), callerCodec, OPTIONS);
+ var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), handlerCodec, OPTIONS);
+ var service = handler.register(TOPIC, RecordArguments.class, (label, item, values, counts) -> {
+ Assertions.assertEquals(items, values);
+ Assertions.assertEquals(numbers, counts);
+ Assertions.assertInstanceOf(Item.class, values.getFirst());
+ Assertions.assertInstanceOf(Integer.class, counts.getFirst());
+ return new Item(label + ":" + (item == null ? "null" : item.name()) + ":" + counts.getFirst(),
+ values.getFirst().tags());
+ })) {
+ RecordArguments proxy = caller.proxy(TOPIC, RecordArguments.class);
+ Assertions.assertEquals(new Item("label:selected:7", List.of("tag")), proxy.apply("label", selected, items, numbers));
+ Assertions.assertEquals(new Item("null:null:7", List.of("tag")), proxy.apply(null, null, items, numbers));
+ List argumentTypes = Arrays.asList(RecordArguments.class
+ .getMethod("apply", String.class, Item.class, List.class, List.class).getGenericParameterTypes());
+ Assertions.assertEquals(8, callerCodec.encodedTypes.size());
+ Assertions.assertEquals(argumentTypes, callerCodec.encodedTypes.subList(0, 4));
+ Assertions.assertEquals(argumentTypes, callerCodec.encodedTypes.subList(4, 8));
+ Assertions.assertEquals(callerCodec.encodedTypes, handlerCodec.decodedTypes);
+ Assertions.assertEquals(List.of(Item.class, Item.class), handlerCodec.encodedTypes);
+ Assertions.assertEquals(handlerCodec.encodedTypes, callerCodec.decodedTypes);
+ Assertions.assertEquals(JsonParser.parseString("[\"label\",{\"name\":\"selected\",\"tags\":[\"chosen\"]},"
+ + "[{\"name\":\"nested\",\"tags\":[\"tag\"]}],[7,8]]"),
+ left.sent.getFirst().payload().getAsJsonObject("data").get("data"));
+ Assertions.assertEquals(JsonParser.parseString("[null,null,[{\"name\":\"nested\",\"tags\":[\"tag\"]}],[7,8]]"),
+ left.sent.getLast().payload().getAsJsonObject("data").get("data"));
+ }
+ }
+
+ @Test
+ void rejectsWrongArgumentCountsAndNullPrimitiveArgumentsAcrossJsonFraming() throws Exception {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = message -> right.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ right.forward = message -> left.deliver(DDRemoteDamProtocol.decode(DDRemoteDamProtocol.encode(message)));
+ AtomicInteger calls = new AtomicInteger();
+ var codec = new DDRemoteDamGsonRpcCodec(new Gson());
+ try (var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, TOPIC), OPTIONS);
+ var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), codec, OPTIONS);
+ var service = handler.register(TOPIC, Numbers.class, (first, second, third) -> {
+ calls.incrementAndGet();
+ return first + second + third;
+ })) {
+ for (List arguments : List.
>of(List.of(), List.of(new JsonPrimitive(1)),
+ List.of(new JsonPrimitive(1), new JsonPrimitive(2)),
+ List.of(new JsonPrimitive(1), new JsonPrimitive(2), new JsonPrimitive(3), new JsonPrimitive(4)),
+ Arrays.asList(new JsonPrimitive(1), null, new JsonPrimitive(3)))) {
+ Assertions.assertEquals(MessagingRpcMessage.failure(MessagingRpcMessage.Error.INVALID_REQUEST),
+ caller.request(MessagingRpcMessage.request("sum(int,int,int)", arguments)));
+ }
+ Assertions.assertEquals(0, calls.get());
+ Assertions.assertEquals(MessagingRpcMessage.response(new JsonPrimitive(6)),
+ caller.request(MessagingRpcMessage.request("sum(int,int,int)",
+ List.of(new JsonPrimitive(1), new JsonPrimitive(2), new JsonPrimitive(3)))));
+ Assertions.assertEquals(1, calls.get());
+ }
+ }
+
+ @Test
+ void preservesCorrelationOuterTypesAndBothErrorNamespaces() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ MessagingRequestReplyMessage> request = request(endpoint, new JsonPrimitive("argument"));
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ Assertions.assertEquals(endpoint, delegate.endpoint);
+ adapter.send(request).toCompletableFuture().join();
+ MessagingRequestReplyMessage sent = delegate.sent.getLast();
+ Assertions.assertEquals(request.requestId(), sent.requestId());
+ Assertions.assertEquals(request.replyTo(), sent.replyTo());
+ Assertions.assertEquals(MessagingRequestReplyMessage.Type.REQUEST, sent.type());
+ Assertions.assertEquals("dbeaver.rpc.v2", Objects.requireNonNull(sent.payload()).get("protocol").getAsString());
+ JsonObject expectedRequest = requestEnvelope();
+ expectedRequest.add("data", JsonParser.parseString("[\"argument\"]"));
+ Assertions.assertEquals(expectedRequest, sent.payload());
+ Assertions.assertFalse(sent.payload().has("topic"));
+ delegate.deliver(sent);
+ Assertions.assertEquals(request, received.messages.getLast());
+ MessagingRequestReplyMessage> response =
+ request.response(MessagingRpcMessage.response(new JsonPrimitive(42)));
+ adapter.send(response).toCompletableFuture().join();
+ JsonObject expectedResponse = responseEnvelope();
+ expectedResponse.addProperty("data", 42);
+ Assertions.assertEquals(expectedResponse, delegate.sent.getLast().payload());
+ delegate.deliver(delegate.sent.getLast());
+ Assertions.assertEquals(response, received.messages.getLast());
+ for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) {
+ MessagingRequestReplyMessage> failure =
+ request.response(MessagingRpcMessage.failure(error));
+ adapter.send(failure).toCompletableFuture().join();
+ MessagingRequestReplyMessage wire = delegate.sent.getLast();
+ Assertions.assertEquals(request.requestId(), wire.requestId());
+ Assertions.assertEquals(request.replyTo(), wire.replyTo());
+ Assertions.assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, wire.type());
+ Assertions.assertNull(wire.error());
+ Assertions.assertFalse(Objects.requireNonNull(wire.payload()).has("data"));
+ Assertions.assertEquals(error.name(), wire.payload().get("error").getAsString());
+ Assertions.assertEquals(JsonNull.INSTANCE, wire.payload().get("exception"));
+ Assertions.assertEquals(errorEnvelope(error), wire.payload());
+ delegate.deliver(wire);
+ Assertions.assertEquals(failure, received.messages.getLast());
+ }
+ for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) {
+ MessagingRequestReplyMessage> failure = request.failure(error);
+ adapter.send(failure).toCompletableFuture().join();
+ MessagingRequestReplyMessage wire = delegate.sent.getLast();
+ Assertions.assertNull(wire.payload());
+ Assertions.assertEquals(error, wire.error());
+ delegate.deliver(wire);
+ Assertions.assertEquals(failure, received.messages.getLast());
+ }
+ adapter.send(request.response(null)).toCompletableFuture().join();
+ delegate.deliver(delegate.sent.getLast());
+ Assertions.assertEquals(request.response(null), received.messages.getLast());
+ }
+ Assertions.assertTrue(delegate.closed);
+ }
+
+ @Test
+ void roundTripsStructuredDiagnosticsAndExplicitNullMessagesWithoutChangingOuterFrames() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ MessagingRequestReplyMessage> request = request(endpoint, null);
+ List diagnostics = List.of(
+ new MessagingRpcExceptionDetails("application.Failure", "quoted \"message\"\n雪😀", "trace\r\n\t\0\\雪😀", false),
+ new MessagingRpcExceptionDetails("application.Failure", null, "", false),
+ new MessagingRpcExceptionDetails("application.Failure", "", "bounded trace", true)
+ );
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) {
+ for (MessagingRpcExceptionDetails details : diagnostics) {
+ MessagingRequestReplyMessage> failure =
+ request.response(MessagingRpcMessage.failure(error, details));
+ adapter.send(failure).toCompletableFuture().join();
+ MessagingRequestReplyMessage wire = delegate.sent.getLast();
+ Assertions.assertEquals(request.requestId(), wire.requestId());
+ Assertions.assertEquals(request.replyTo(), wire.replyTo());
+ Assertions.assertEquals(MessagingRequestReplyMessage.Type.RESPONSE, wire.type());
+ Assertions.assertNull(wire.error());
+ JsonObject envelope = Objects.requireNonNull(wire.payload());
+ Assertions.assertEquals(Set.of("protocol", "type", "error", "exception"), envelope.keySet());
+ final JsonObject expected = errorEnvelope(error);
+ JsonObject exception = new JsonObject();
+ exception.addProperty("type", details.type());
+ exception.add("message", details.message() == null ? JsonNull.INSTANCE : new JsonPrimitive(details.message()));
+ exception.addProperty("stackTrace", details.stackTrace());
+ exception.addProperty("truncated", details.truncated());
+ expected.add("exception", exception);
+ Assertions.assertEquals(expected, envelope);
+ delegate.deliver(new MessagingRequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(),
+ JsonParser.parseString(envelope.toString()).getAsJsonObject(), wire.error()));
+ Assertions.assertEquals(failure, received.messages.getLast());
+ }
+ }
+ Assertions.assertEquals(MessagingRpcMessage.Error.values().length * diagnostics.size(), received.messages.size());
+ }
+ }
+
+ @Test
+ void maximumDiagnosticsFitTheFullFramedMessageBudgetUnderEscapingAndUnicode() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, "x".repeat(256));
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ MessagingRequestReplyMessage> request = request(endpoint, null);
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ for (String unit : List.of("\0", "\"\\\n\r\t\b\f\37", "\u2028x\u2029x", "雪", "😀")) {
+ MessagingRpcExceptionDetails details = new MessagingRpcExceptionDetails(
+ unit.repeat(MessagingRpcExceptionDetails.MAX_TYPE_LENGTH / unit.length()),
+ unit.repeat(MessagingRpcExceptionDetails.MAX_MESSAGE_LENGTH / unit.length()),
+ unit.repeat(MessagingRpcExceptionDetails.MAX_STACK_TRACE_LENGTH / unit.length()), true);
+ Assertions.assertEquals(256, details.type().length());
+ Assertions.assertEquals(1024, Objects.requireNonNull(details.message()).length());
+ Assertions.assertEquals(8192, details.stackTrace().length());
+ MessagingRequestReplyMessage> failure = request.response(
+ MessagingRpcMessage.failure(MessagingRpcMessage.Error.UNKNOWN_OPERATION, details));
+ adapter.send(failure).toCompletableFuture().join();
+ DDRemoteDamMessage wire = channel.sent.getLast();
+ Assertions.assertEquals("datadam.requestreply.v1", wire.type());
+ JsonObject outer = wire.payload();
+ Assertions.assertEquals(Set.of("requestId", "replyTo", "type", "data"), outer.keySet());
+ Assertions.assertEquals(request.requestId().toString(), outer.get("requestId").getAsString());
+ Assertions.assertEquals(endpoint.toString(), outer.get("replyTo").getAsString());
+ Assertions.assertEquals("RESPONSE", outer.get("type").getAsString());
+ Assertions.assertEquals("dbeaver.rpc.v2", outer.getAsJsonObject("data").get("protocol").getAsString());
+ String encoded = DDRemoteDamProtocol.encode(wire);
+ Assertions.assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= 65_536);
+ channel.deliver(DDRemoteDamProtocol.decode(encoded));
+ Assertions.assertEquals(failure, received.messages.getLast());
+ }
+ Assertions.assertEquals(5, channel.sent.size());
+ Assertions.assertEquals(5, received.messages.size());
+ }
+ }
+
+ @Test
+ void rejectsMalformedExceptionDetailsInErrorEnvelopes() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ MessagingRequestReplyMessage request =
+ MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope());
+ List malformed = malformedExceptions();
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) {
+ for (JsonElement details : malformed) {
+ JsonObject envelope = errorEnvelope(error);
+ envelope.add("exception", details);
+ delegate.deliver(request.response(envelope));
+ }
+ }
+ Assertions.assertTrue(received.messages.isEmpty());
+ }
+ }
+
+ @Test
+ void roundTripsZeroSingleAndMultipleArgumentsAsJsonArrays() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ List> cases = List.of(List.of(), Collections.singletonList(null), List.of(new JsonPrimitive("single")),
+ Arrays.asList(new JsonPrimitive("first"), null, new JsonPrimitive(3)),
+ List.of(JsonParser.parseString("[1,null,3]"), JsonNull.INSTANCE, JsonParser.parseString("{\"name\":\"record\"}")));
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ for (List arguments : cases) {
+ MessagingRequestReplyMessage> request = MessagingRequestReplyMessage.request(
+ UUID.randomUUID(), endpoint, MessagingRpcMessage.request("apply", arguments));
+ adapter.send(request).toCompletableFuture().join();
+ MessagingRequestReplyMessage wire = delegate.sent.getLast();
+ JsonObject envelope = JsonParser.parseString(Objects.requireNonNull(wire.payload()).toString()).getAsJsonObject();
+ Assertions.assertEquals(Set.of("protocol", "type", "operation", "data"), envelope.keySet());
+ JsonArray expected = new JsonArray();
+ arguments.forEach(expected::add);
+ Assertions.assertEquals(expected, envelope.get("data"));
+ delegate.deliver(new MessagingRequestReplyMessage<>(wire.requestId(), wire.replyTo(), wire.type(), envelope, wire.error()));
+ MessagingRequestReplyMessage> decoded = received.messages.getLast();
+ Assertions.assertEquals(request.requestId(), decoded.requestId());
+ Assertions.assertEquals(endpoint, decoded.replyTo());
+ Assertions.assertEquals(MessagingRequestReplyMessage.Type.REQUEST, decoded.type());
+ MessagingRpcMessage rpc = Objects.requireNonNull(decoded.payload());
+ Assertions.assertEquals(MessagingRpcMessage.Type.REQUEST, rpc.type());
+ Assertions.assertEquals("apply", rpc.operation());
+ Assertions.assertEquals(arguments.stream().map(value -> value == null || value.isJsonNull() ? null : value).toList(),
+ rpc.arguments());
+ Assertions.assertNull(rpc.payload());
+ Assertions.assertNull(rpc.error());
+ Assertions.assertNull(rpc.exception());
+ }
+ Assertions.assertEquals(cases.size(), received.messages.size());
+ }
+ }
+
+ @Test
+ void acceptsArbitraryJsonValuesAndNormalizesEncodedNull() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ List values = List.of(JsonParser.parseString("{\"items\":[1,true,null]}"),
+ JsonParser.parseString("[\"a\",2,null]"), new JsonPrimitive("text"), new JsonPrimitive(42),
+ new JsonPrimitive(true), JsonNull.INSTANCE);
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ for (JsonElement value : values) {
+ MessagingRequestReplyMessage> request = request(endpoint, value);
+ adapter.send(request).toCompletableFuture().join();
+ MessagingRequestReplyMessage wire = delegate.sent.getLast();
+ JsonArray arguments = Objects.requireNonNull(wire.payload()).getAsJsonArray("data");
+ Assertions.assertEquals(1, arguments.size());
+ Assertions.assertEquals(value, arguments.get(0));
+ delegate.deliver(wire);
+ MessagingRpcMessage decoded = Objects.requireNonNull(received.messages.getLast().payload());
+ Assertions.assertNull(decoded.payload());
+ Assertions.assertEquals(1, decoded.arguments().size());
+ Assertions.assertEquals(value.isJsonNull() ? null : value,
+ decoded.arguments().getFirst());
+ adapter.send(request.response(MessagingRpcMessage.response(value))).toCompletableFuture().join();
+ Assertions.assertEquals(value, Objects.requireNonNull(delegate.sent.getLast().payload()).get("data"));
+ delegate.deliver(delegate.sent.getLast());
+ Assertions.assertEquals(MessagingRpcMessage.Type.RESPONSE,
+ Objects.requireNonNull(received.messages.getLast().payload()).type());
+ Assertions.assertTrue(received.messages.getLast().payload().arguments().isEmpty());
+ Assertions.assertEquals(value.isJsonNull() ? null : value, received.messages.getLast().payload().payload());
+ }
+ }
+ }
+
+ @Test
+ void isolatesSnapshotsEvenWhenTheBorrowedDelegateDoesNotCopy() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ for (MessagingRequestReplyMessage.Type type : List.of(
+ MessagingRequestReplyMessage.Type.REQUEST, MessagingRequestReplyMessage.Type.RESPONSE)) {
+ JsonObject argument = JsonParser.parseString("{\"items\":[\"original\"]}").getAsJsonObject();
+ MessagingRequestReplyMessage> request = request(endpoint, argument);
+ adapter.send(type == MessagingRequestReplyMessage.Type.REQUEST
+ ? request : request.response(MessagingRpcMessage.response(argument)));
+ JsonObject wire = Objects.requireNonNull(delegate.sent.getLast().payload());
+ JsonObject wireValue = (type == MessagingRequestReplyMessage.Type.REQUEST
+ ? wire.getAsJsonArray("data").get(0) : wire.get("data")).getAsJsonObject();
+ argument.getAsJsonArray("items").add("caller");
+ Assertions.assertEquals(1, wireValue.getAsJsonArray("items").size());
+ delegate.deliver(delegate.sent.getLast());
+ MessagingRpcMessage receivedMessage = Objects.requireNonNull(received.messages.getLast().payload());
+ JsonObject decoded = Objects.requireNonNull(type == MessagingRequestReplyMessage.Type.REQUEST
+ ? receivedMessage.arguments().getFirst() : receivedMessage.payload()).getAsJsonObject();
+ wireValue.getAsJsonArray("items").add("delegate");
+ Assertions.assertEquals(1, decoded.getAsJsonArray("items").size());
+ decoded.getAsJsonArray("items").add("listener");
+ Assertions.assertEquals("delegate", wireValue.getAsJsonArray("items").get(1).getAsString());
+ Assertions.assertEquals("caller", argument.getAsJsonArray("items").get(1).getAsString());
+ }
+ }
+ }
+
+ @Test
+ void dropsMalformedVersionsStringFieldsNamesAndRequestShapes() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ Received received = new Received();
+ UUID endpoint = UUID.randomUUID();
+ MessagingRequestReplyMessage request =
+ MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope());
+ List nonStrings = List.of(new JsonPrimitive(1), new JsonPrimitive(true), JsonNull.INSTANCE,
+ new JsonObject(), new JsonArray());
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ for (String field : List.of("protocol", "type", "operation")) {
+ JsonObject missing = requestEnvelope();
+ missing.remove(field);
+ delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, missing));
+ for (JsonElement value : nonStrings) {
+ JsonObject invalid = requestEnvelope();
+ invalid.add(field, value);
+ delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid));
+ }
+ }
+ List> malformed = List.of(
+ envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"),
+ envelope -> envelope.addProperty("protocol", "unrelated"),
+ envelope -> envelope.addProperty("type", "OTHER"),
+ envelope -> envelope.addProperty("type", "request"),
+ envelope -> envelope.addProperty("type", "RESPONSE"),
+ envelope -> envelope.addProperty("type", "ERROR"),
+ envelope -> envelope.addProperty("topic", "legacy.topic"),
+ envelope -> envelope.addProperty("operation", "\t"),
+ envelope -> envelope.addProperty("operation", "x".repeat(1025)),
+ envelope -> envelope.remove("data"),
+ envelope -> envelope.add("error", JsonNull.INSTANCE),
+ envelope -> envelope.add("exception", exceptionEnvelope()),
+ envelope -> envelope.addProperty("extra", true)
+ );
+ for (Consumer mutation : malformed) {
+ JsonObject invalid = requestEnvelope();
+ mutation.accept(invalid);
+ delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid));
+ }
+ for (JsonElement data : List.of(JsonNull.INSTANCE, new JsonPrimitive("legacy scalar"),
+ new JsonPrimitive("[]"), new JsonPrimitive(1), new JsonPrimitive(true), new JsonObject())) {
+ JsonObject invalid = requestEnvelope();
+ invalid.add("data", data);
+ delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, invalid));
+ }
+ Assertions.assertTrue(received.messages.isEmpty());
+ JsonObject boundary = requestEnvelope();
+ boundary.addProperty("operation", "x".repeat(1024));
+ delegate.deliver(MessagingRequestReplyMessage.request(request.requestId(), endpoint, boundary));
+ Assertions.assertEquals(1, received.messages.size());
+ MessagingRpcMessage accepted = Objects.requireNonNull(received.messages.getLast().payload());
+ Assertions.assertEquals("x".repeat(1024), accepted.operation());
+ Assertions.assertEquals(Collections.singletonList(null), accepted.arguments());
+ Assertions.assertNull(accepted.payload());
+ }
+ }
+
+ @Test
+ void dropsMalformedResponseAndNestedErrorShapes() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ DDRemoteDamRpcTransport adapter = new DDRemoteDamRpcTransport(delegate);
+ Received received = new Received();
+ UUID endpoint = UUID.randomUUID();
+ MessagingRequestReplyMessage request =
+ MessagingRequestReplyMessage.request(UUID.randomUUID(), endpoint, requestEnvelope());
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ List> malformedResponses = List.of(
+ envelope -> envelope.remove("data"),
+ envelope -> envelope.add("topic", JsonNull.INSTANCE),
+ envelope -> envelope.addProperty("operation", "apply"),
+ envelope -> envelope.add("error", JsonNull.INSTANCE),
+ envelope -> envelope.add("exception", exceptionEnvelope()),
+ envelope -> envelope.addProperty("protocol", 1),
+ envelope -> envelope.addProperty("type", true),
+ envelope -> envelope.addProperty("type", "OTHER")
+ );
+ for (Consumer mutation : malformedResponses) {
+ JsonObject invalid = responseEnvelope();
+ mutation.accept(invalid);
+ delegate.deliver(request.response(invalid));
+ }
+ delegate.deliver(request.response(requestEnvelope()));
+ List> malformedErrors = List.of(
+ envelope -> envelope.remove("error"),
+ envelope -> envelope.remove("exception"),
+ envelope -> envelope.addProperty("error", "OTHER"),
+ envelope -> envelope.addProperty("error", 1),
+ envelope -> envelope.addProperty("error", true),
+ envelope -> envelope.add("error", JsonNull.INSTANCE),
+ envelope -> envelope.add("error", new JsonObject()),
+ envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"),
+ envelope -> envelope.addProperty("type", "OTHER"),
+ envelope -> envelope.add("data", JsonNull.INSTANCE),
+ envelope -> envelope.addProperty("topic", "names"),
+ envelope -> envelope.add("operation", JsonNull.INSTANCE)
+ );
+ for (Consumer mutation : malformedErrors) {
+ for (JsonElement details : List.of(JsonNull.INSTANCE, exceptionEnvelope())) {
+ JsonObject invalid = errorEnvelope(MessagingRpcMessage.Error.HANDLER_FAILED);
+ invalid.add("exception", details);
+ mutation.accept(invalid);
+ delegate.deliver(request.response(invalid));
+ }
+ }
+ Assertions.assertTrue(received.messages.isEmpty());
+ delegate.deliver(request.response(responseEnvelope()));
+ delegate.deliver(request.response(errorEnvelope(MessagingRpcMessage.Error.HANDLER_FAILED)));
+ Assertions.assertEquals(2, received.messages.size());
+ }
+ }
+
+ @Test
+ void requiresObjectEnvelopesThroughExistingDataDamFraming() throws Exception {
+ FakeChannel channel = new FakeChannel();
+ DDRemoteDamRpcTransport adapter = DDRemoteDamRpcTransport.forChannel(channel, TOPIC);
+ UUID endpoint = UUID.randomUUID();
+ Received received = new Received();
+ try (var subscription = adapter.subscribe(endpoint, received)) {
+ adapter.send(request(endpoint, null)).toCompletableFuture().join();
+ DDRemoteDamMessage valid = channel.sent.getLast();
+ for (JsonElement value : List.of(new JsonPrimitive("text"), new JsonArray(), JsonNull.INSTANCE)) {
+ JsonObject invalid = valid.payload();
+ invalid.add("data", value);
+ channel.deliver(new DDRemoteDamMessage(valid.topic(), valid.type(), invalid));
+ }
+ Assertions.assertTrue(received.messages.isEmpty());
+ channel.deliver(valid);
+ Assertions.assertEquals(1, received.messages.size());
+ }
+ }
+
+ @Test
+ void neutralRpcDistinguishesNestedErrorsOuterErrorsAndInvalidResponses() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRemoteDamRpcTransport(delegate), CODEC, OPTIONS)) {
+ Text proxy = endpoint.proxy("text", Text.class);
+ for (MessagingRpcMessage.Error error : MessagingRpcMessage.Error.values()) {
+ delegate.forward = request -> delegate.deliver(request.response(errorEnvelope(error)));
+ MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request"));
+ Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason());
+ Assertions.assertEquals(error, failure.remoteError());
+ Assertions.assertNull(failure.remoteException());
+ Assertions.assertNull(failure.getCause());
+ Assertions.assertEquals("Remote RPC call failed: " + error, failure.getMessage());
+ }
+ for (MessagingRequestReplyMessage.Error error : MessagingRequestReplyMessage.Error.values()) {
+ delegate.forward = request -> delegate.deliver(request.failure(error));
+ MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request"));
+ Assertions.assertEquals(MessagingRpcException.Reason.REMOTE_ERROR, failure.reason());
+ Assertions.assertNull(failure.remoteError());
+ Assertions.assertNull(failure.remoteException());
+ MessagingRequestReplyException outer =
+ Assertions.assertInstanceOf(MessagingRequestReplyException.class, failure.getCause());
+ Assertions.assertEquals(error, outer.remoteError());
+ }
+ delegate.forward = request -> delegate.deliver(request.response(null));
+ MessagingRpcException invalid = Assertions.assertThrows(MessagingRpcException.class, () -> proxy.apply("request"));
+ Assertions.assertEquals(MessagingRpcException.Reason.INVALID_RESPONSE, invalid.reason());
+ }
+ }
+
+ @Test
+ void malformedAndUnknownVersionRepliesRemainUnansweredUntilTimeout() throws Exception {
+ MemoryTransport delegate = new MemoryTransport();
+ MessagingRequestReplyOptions options = new MessagingRequestReplyOptions(Duration.ofMillis(100), 4, 2);
+ try (var endpoint = MessagingRpcEndpoint.open(topic -> new DDRemoteDamRpcTransport(delegate), CODEC, options)) {
+ for (Consumer mutation : List.>of(
+ envelope -> envelope.addProperty("protocol", "dbeaver.rpc.v1"),
+ envelope -> envelope.addProperty("type", "UNKNOWN"),
+ envelope -> envelope.remove("data")
+ )) {
+ JsonObject invalid = responseEnvelope();
+ mutation.accept(invalid);
+ delegate.forward = request -> delegate.deliver(request.response(invalid));
+ MessagingRpcException failure = Assertions.assertThrows(MessagingRpcException.class,
+ () -> endpoint.proxy("text", Text.class).apply("request"));
+ Assertions.assertEquals(MessagingRpcException.Reason.TIMEOUT, failure.reason());
+ }
+ Assertions.assertEquals(3, delegate.sent.size());
+ }
+ }
+
+ @Test
+ void dispatchesRealRpcFailuresInsideSuccessfulRequestReplyResponses() throws Exception {
+ FakeChannel left = new FakeChannel();
+ FakeChannel right = new FakeChannel();
+ left.forward = right::deliver;
+ right.forward = left::deliver;
+ try (var caller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, TOPIC), OPTIONS);
+ var missingCaller = MessagingRequestReplyEndpoint.open(DDRemoteDamRpcTransport.forChannel(left, "missing"), OPTIONS);
+ var handler = MessagingRpcEndpoint.open(DDRemoteDamRpcTransport.forChannel(right), CODEC, OPTIONS);
+ var service = handler.register(TOPIC, Text.class, value -> {
+ throw new IllegalStateException("Handler failed");
+ })) {
+ handler.proxy("missing", Text.class);
+ MessagingRpcMessage