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:
| # | Primitive | Shape | What it’s for |
|---|---|---|---|
| 1 | BaseEntity | record CRUD | save / load / delete a single row, with validation + Record Changes |
| 2 | RunView | dynamic set reads | filter / sort / page over an entity at runtime |
| 3 | RunQuery | stored, parameterized queries | run a named, pre-defined query (often cross-entity / hand-tuned SQL) by key |
| 4 | BaseRemotableOperation | typed 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 routing —
entity.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.
When to use what
Section titled “When to use what”| Need | Tool |
|---|---|
| Table-backed record CRUD | BaseEntity (already generated) |
| A dynamic filtered/sorted read over an entity | RunView |
| A stored, parameterized query by key | RunQuery |
| A typed capability the browser and server both invoke, one type system | BaseRemotableOperation (this guide) |
| A metadata/string-discoverable boundary for agents / workflows / low-code | Action |
| 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.
Three ways to author an operation
Section titled “Three ways to author an operation”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:
GenerationType | CodeGen emits | You write |
|---|---|---|
Manual | a typed base shell (no body, no @RegisterClass) | a hand-authored server subclass with InternalExecute + @RegisterClass |
AI | a complete registered class with an LLM-authored body (after approval) | nothing — describe it in Description and approve the code |
Default | a complete registered class with default/boilerplate plumbing | nothing |
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.
1 — Manual (hand-authored subclass)
Section titled “1 — Manual (hand-authored subclass)”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.
2 — AI-from-Description (RO-4)
Section titled “2 — AI-from-Description (RO-4)”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 payloadprovider: IMetadataProvider—RunView.FromMetadataProvider(provider),provider.GetEntityObject(name, user)user: UserInfo— the acting user (threaded into every data call)context: RemoteOpServerContext—context.emitProgress(...)forLongRunning- the default libraries (
RunView,Metadata,RunQueryfrom@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.
3 — Default plumbing
Section titled “3 — Default plumbing”GenerationType='Default' emits a complete class with boilerplate plumbing (no LLM). Useful for rote shapes;
the smallest deployment footprint.
Calling an operation
Section titled “Calling an operation”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:
Executeroutes throughGraphQLDataProvider, which marshals the call over the genericExecuteRemoteOperationmutation and parses the typed result back. - Server:
Executeroutes throughDatabaseProviderBase, which resolves the operation by key from the ClassFactory and runs it in-process viaExecuteServer.
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.
The RouteOperation power tool
Section titled “The RouteOperation power tool”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.
Authorization
Section titled “Authorization”The server resolver (ExecuteRemoteOperationResolver) composes the existing auth seams — an operation never
requires an API key:
- Resolve the acting user (
GetUserFromPayload) — works for interactive OAuth/JWT and API-key sessions. - API-key scope gate (
CheckAPIKeyScopeAuthorization(RequiredScope, key, userPayload)) — a no-op for interactive users, enforced for API-key / MCP callers;RequiredScopeis optional and seeded as anMJ: API Scopesrow (e.g.recordprocess:execute). - Metadata gate (
RemoteOperationEngineBase.IsInvokable) — defense-in-depth: the operation’s row must beActive, andAIops must additionally beApproved, 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. - System-user gate (
RequiresSystemUser) and the entity-permission ceiling, as the operation touches data. - 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.
Long-running operations (RO-3)
Section titled “Long-running operations (RO-3)”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.RunNowforwards theRecordSetProcessor’s per-batch progress this way. - Over the wire — the client (
GraphQLDataProvider) opens aRemoteOperationProgressGraphQL subscription with a self-generatedchannelId, passes that id to theExecuteRemoteOperationmutation, and the server publishes each emittedRemoteOpProgressto that channel (@Subscription/@PubSubinExecuteRemoteOperationResolver). Filtered bychannelIdso 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’sonProgressfires live during aLongRunningmutation — no API change, just passonProgress.
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.
Reference implementations
Section titled “Reference implementations”- AI-authored body:
MJRemoteOperationEntityServer(@memberjunction/core-entities-server) + theGenerate Remote Operation Codeprompt +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.InternalRouteOperation→dispatchRemoteOperationInProcess(server, in@memberjunction/core);GraphQLDataProvider.InternalRouteOperation(client,@memberjunction/graphql-dataprovider); the genericExecuteRemoteOperationResolver+RemoteOperationProgresssubscription (@memberjunction/server).
Status
Section titled “Status”| Phase | What | Status |
|---|---|---|
| RO-0 | BaseRemotableOperation + IRemoteOperationProvider + in-process dispatch | ✅ shipped |
| RO-1 | ExecuteRemoteOperation resolver + auth chain | ✅ shipped |
| RO-2 | MJ: Remote Operations metadata + CodeGen emitter + RemoteOperationEngineBase cache | ✅ shipped |
| RO-3 | LongRunning progress — attached, in-process AND over-the-wire (subscription channel) | ✅ shipped · detached fire-and-forget partial |
| RO-4 | AI-from-Description operation bodies | ✅ shipped |
See plans/record-set-processing-and-record-processes.md §16–§17 for the full design history.