Skip to content

Remote Operations Guide

Remote Operations are MemberJunction’s 4th data primitive — a typed, provider-routed server capability that the browser and server both invoke through one call site. They complete the set you already use every day:

#PrimitiveShapeWhat it’s for
1BaseEntityrecord CRUDsave / load / delete a single row, with validation + Record Changes
2RunViewdynamic set readsfilter / sort / page over an entity at runtime
3RunQuerystored, parameterized queriesrun a named, pre-defined query (often cross-entity / hand-tuned SQL) by key
4BaseRemotableOperationtyped RPC”run X on the server” — typed structured input → output that isn’t a read or a CRUD write

All four share one shape: one call site that works on client and server, with the provider routingentity.Save(), rv.RunView(), rq.RunQuery(), and now op.Execute(). On the client each marshals over GraphQL; on the server each runs in-process. You write the same code regardless of tier.

So the mental slot for a Remote Operation is: “I have a server capability that’s neither a row write (BaseEntity), a dynamic query (RunView), nor a stored query (RunQuery) — it takes typed input and returns typed output.” Running a record process, rendering a template, kicking off clustering, classifying text.

Read this before hand-writing a TypeGraphQL resolver + a typed GraphQL client + duplicated I/O types for a non-CRUD capability. That ceremony is exactly what Remote Operations replaces.

📊 New to the idea? Start with the Remote Operations Showcase — a visual before/after (with diagrams) of the layers this removes, built from two real migrations in this PR.

NeedTool
Table-backed record CRUDBaseEntity (already generated)
A dynamic filtered/sorted read over an entityRunView
A stored, parameterized query by keyRunQuery
A typed capability the browser and server both invoke, one type systemBaseRemotableOperation (this guide)
A metadata/string-discoverable boundary for agents / workflows / low-codeAction
A first-class public GraphQL API surface, or an unusual transport (upload, native stream)bespoke typed resolver

Remote Operations is not a replacement for Actions. Actions are the agent surface (their I/O is an untyped ActionParam bag, by design); Remote Operations add compile-time typed structured I/O declared once and shared by both sides, a structured-object input model, and a code-oriented registry distinct from the agent catalog.

Every operation is a subclass of BaseRemotableOperation<TInput, TOutput> registered under a stable key. The typed base (key, execution mode, scope, and the TInput/TOutput interfaces) can be hand-written or, now, CodeGen-emitted from an MJ: Remote Operations metadata row — the typed peer of generated entity subclasses. How much you write depends on the row’s GenerationType:

GenerationTypeCodeGen emitsYou write
Manuala typed base shell (no body, no @RegisterClass)a hand-authored server subclass with InternalExecute + @RegisterClass
AIa complete registered class with an LLM-authored body (after approval)nothing — describe it in Description and approve the code
Defaulta complete registered class with default/boilerplate plumbingnothing

The generated bases land in @memberjunction/core-entities (generated/remote_operations.ts) for the MJ repo, or a downstream repo’s GeneratedEntities — parallel to the entity-subclass split (CoreRemoteOperations / RemoteOperations CodeGen output config). The emitter de-dups shared type definitions and emits each operation’s declared library imports + the always-available defaults.

Add an MJ: Remote Operations row with GenerationType='Manual' and the typed I/O definitions; CodeGen emits the base, and you supply the body in a server subclass that extends it:

import { RegisterClass } from '@memberjunction/global';
import { BaseRemotableOperation, IMetadataProvider, UserInfo } from '@memberjunction/core';
// CodeGen-emitted base (OperationKey / ExecutionMode / RequiredScope / typed I/O all come from metadata):
import { RecordProcessGetRunStatusOperation, type RecordProcessGetRunStatusInput, type RecordProcessGetRunStatusOutput } from '@memberjunction/core-entities';
@RegisterClass(BaseRemotableOperation, 'RecordProcess.GetRunStatus')
export class RecordProcessGetRunStatusServerOperation extends RecordProcessGetRunStatusOperation {
// Use `provider`/`user` for data access (never `new Metadata()`); the typed signature comes from the base.
protected async InternalExecute(input: RecordProcessGetRunStatusInput, provider: IMetadataProvider, user: UserInfo): Promise<RecordProcessGetRunStatusOutput> {
const run = await provider.GetEntityObject('MJ: Process Runs', user);
await run.Load(input.processRunID);
return { status: run.Status, processed: run.ProcessedItems, total: run.TotalItemCount, success: run.SuccessCount, error: run.ErrorCount, skipped: run.SkippedCount };
}
}

The server subclass registers last (it imports + extends the generated base), so it wins ClassFactory dispatch by key.

Set GenerationType='AI' and a clear Description; no hand-written subclass at all. On save, MJRemoteOperationEntityServer calls the Generate Remote Operation Code prompt to author the body of InternalExecute, stores it in Code, records the libraries it used in LibrariesObject, and sets CodeApprovalStatus='Pending'. After a human approves, CodeGen emits a complete @RegisterClass’d class with that body. Changing the Description re-generates and re-resets approval (unless CodeLocked).

The ambient contract the model codes against — taught by the generation prompt:

  • input: TInput — the typed payload
  • provider: IMetadataProviderRunView.FromMetadataProvider(provider), provider.GetEntityObject(name, user)
  • user: UserInfo — the acting user (threaded into every data call)
  • context: RemoteOpServerContextcontext.emitProgress(...) for LongRunning
  • the default libraries (RunView, Metadata, RunQuery from @memberjunction/core), imported for every body

Anything beyond the defaults is declared by the model and stored in the Libraries JSONType field (Array<RemoteOperationLibrary> of { Library, ItemsUsed[] }); the emitter turns each entry into an import. Most ops — “RunView some rows, shape them, return” — declare zero libraries.

GenerationType='Default' emits a complete class with boilerplate plumbing (no LLM). Useful for rote shapes; the smallest deployment footprint.

The same call site works on the client and the server:

const result = await new TemplateRunOperation().Execute({ templateID, data });
if (result.Success) {
console.log(result.Output?.output); // typed TOutput
}
  • Client: Execute routes through GraphQLDataProvider, which marshals the call over the generic ExecuteRemoteOperation mutation and parses the typed result back.
  • Server: Execute routes through DatabaseProviderBase, which resolves the operation by key from the ClassFactory and runs it in-process via ExecuteServer.

Both never throw for logical failures — inspect result.Success / result.ResultCode / result.ErrorMessage. Server-side callers must pass the acting user in the options (Execute(input, { provider, user })); the browser session supplies it automatically.

IRemoteOperationProvider.RouteOperation(key, input, options) (implemented once in ProviderBase) is the public, stringly-typed transport seam. Prefer the typed Operation.Execute()RouteOperation is for dynamic dispatch / generic tooling, not for building significant systems. Only registered operations are routable, and every call is authorized server-side, so it is safe by construction.

The server resolver (ExecuteRemoteOperationResolver) composes the existing auth seams — an operation never requires an API key:

  1. Resolve the acting user (GetUserFromPayload) — works for interactive OAuth/JWT and API-key sessions.
  2. API-key scope gate (CheckAPIKeyScopeAuthorization(RequiredScope, key, userPayload)) — a no-op for interactive users, enforced for API-key / MCP callers; RequiredScope is optional and seeded as an MJ: API Scopes row (e.g. recordprocess:execute).
  3. Metadata gate (RemoteOperationEngineBase.IsInvokable) — defense-in-depth: the operation’s row must be Active, and AI ops must additionally be Approved, so a registered-but-disabled (or unapproved AI) operation is rejected on the wire even though its class is still in code. A code-only op with no row passes.
  4. System-user gate (RequiresSystemUser) and the entity-permission ceiling, as the operation touches data.
  5. The operation’s own Authorize(input, user) hook.

So a logged-in user is bounded by their role/entity permissions; an API-key caller additionally clears the scope.

Set ExecutionMode = 'LongRunning' for operations backed by a tracked run (e.g. a ProcessRun); the handle is the run ID, and the body reports progress through the execution context:

// inside InternalExecute(input, provider, user, context):
context.emitProgress({ OperationKey: this.OperationKey, Processed: n, Total: t, Status: 'Running', Message: `` });

Attached, both tiers (shipped). Operations emit typed RemoteOpProgress, and an attached caller’s onProgress receives it:

  • In-process — immediate (emitProgress → onProgress). RecordProcess.RunNow forwards the RecordSetProcessor’s per-batch progress this way.
  • Over the wire — the client (GraphQLDataProvider) opens a RemoteOperationProgress GraphQL subscription with a self-generated channelId, passes that id to the ExecuteRemoteOperation mutation, and the server publishes each emitted RemoteOpProgress to that channel (@Subscription/@PubSub in ExecuteRemoteOperationResolver). Filtered by channelId so concurrent calls never interleave; progress is best-effort (a channel error never fails the call); the subscription is torn down when the call ends. A browser caller’s onProgress fires live during a LongRunning mutation — no API change, just pass onProgress.

Detached mode (partial). Today mode: 'detached' still completes synchronously and returns the run handle (ProcessRunID), which the caller can poll via RecordProcess.GetRunStatus — correct, but not yet fire-and-forget. True return-immediately background execution + completion notification is the remaining piece; it needs a durable execution context (so the run outlives the request) and a completion-notification mechanism — the latter is an open design decision (reuse the push/notification entity vs. a new one) called out in plans/record-set-processing-and-record-processes.md §17. Deliberately not rushed.

  • AI-authored body: MJRemoteOperationEntityServer (@memberjunction/core-entities-server) + the Generate Remote Operation Code prompt + remote-operation-generation.template.md.
  • CodeGen emitter: RemoteOperationGeneratorBase (@memberjunction/codegen-lib) → remote_operations.ts.
  • Metadata gate: RemoteOperationEngineBase (@memberjunction/core-entities).
  • Operations: Template.Run (@memberjunction/templates); RecordProcess.RunNow (LongRunning, emits progress) / GetRunStatus / PauseRun / ResumeRun / CancelRun (@memberjunction/record-set-processor).
  • Transport: DatabaseProviderBase.InternalRouteOperationdispatchRemoteOperationInProcess (server, in @memberjunction/core); GraphQLDataProvider.InternalRouteOperation (client, @memberjunction/graphql-dataprovider); the generic ExecuteRemoteOperationResolver + RemoteOperationProgress subscription (@memberjunction/server).
PhaseWhatStatus
RO-0BaseRemotableOperation + IRemoteOperationProvider + in-process dispatch✅ shipped
RO-1ExecuteRemoteOperation resolver + auth chain✅ shipped
RO-2MJ: Remote Operations metadata + CodeGen emitter + RemoteOperationEngineBase cache✅ shipped
RO-3LongRunning progress — attached, in-process AND over-the-wire (subscription channel)✅ shipped · detached fire-and-forget partial
RO-4AI-from-Description operation bodies✅ shipped

See plans/record-set-processing-and-record-processes.md §16–§17 for the full design history.