From 2692023851e440f1bf0a363fbbdfb4c4153e5555 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Fri, 31 Oct 2025 16:03:12 +0100 Subject: [PATCH] feat: implement batch executor --- .../services/sdk/rpc/batch-executor.spec.ts | 176 ++++++++++++++++++ .../services/sdk/rpc/batch-executor.ts | 89 +++++++++ .../services/sdk/rpc/reference-store.ts | 4 + 3 files changed, 269 insertions(+) create mode 100644 libs/common/src/platform/services/sdk/rpc/batch-executor.spec.ts create mode 100644 libs/common/src/platform/services/sdk/rpc/batch-executor.ts diff --git a/libs/common/src/platform/services/sdk/rpc/batch-executor.spec.ts b/libs/common/src/platform/services/sdk/rpc/batch-executor.spec.ts new file mode 100644 index 00000000000..543746f6809 --- /dev/null +++ b/libs/common/src/platform/services/sdk/rpc/batch-executor.spec.ts @@ -0,0 +1,176 @@ +import { executeBatchCommands } from "./batch-executor"; +import { RpcError } from "./error"; +import { BatchCommand, serializeSymbol } from "./protocol"; +import { ReferenceStore } from "./reference-store"; + +describe("Batch executor", () => { + let target: TestTarget; + let referenceStore: ReferenceStore; + + beforeEach(() => { + target = new TestTarget(); + referenceStore = new ReferenceStore(); + }); + + it("returns error when command list is empty", async () => { + const commands: BatchCommand[] = []; + + const response = await executeBatchCommands(target, commands, referenceStore); + expect(response).toEqual({ status: "error", error: expect.any(RpcError) }); + }); + + it.each([ + ["propString", "test"], + ["propNumber", 42], + ["propBoolean", true], + ["propNull", null], + ["propUndefined", undefined], + ])("returns value of property when value is a primitive", async (propertyName, expectedValue) => { + const commands: BatchCommand[] = [{ method: "get", propertyName }]; + + const response = await executeBatchCommands(target, commands, referenceStore); + + expect(response).toEqual({ + status: "success", + result: { + type: "value", + value: expectedValue, + }, + }); + expect(referenceStore.size).toBe(0); + }); + + it("returns reference of property when value is an object", async () => { + const commands: BatchCommand[] = [{ method: "get", propertyName: "propObject" }]; + + const response = await executeBatchCommands(target, commands, referenceStore); + expect(response).toEqual({ + status: "success", + result: { + type: "reference", + referenceId: 1, + objectType: "TestObject", + }, + }); + expect(referenceStore.size).toBe(1); + expect(referenceStore.get(1)).toEqual(new TestObject("example")); + }); + + it("returns value of property when transfer is explicitly requested", async () => { + const commands: BatchCommand[] = [ + { method: "get", propertyName: "propObject" }, + { method: "transfer" }, + ]; + + const response = await executeBatchCommands(target, commands, referenceStore); + expect(response).toEqual({ + status: "success", + result: { + type: "value", + value: new TestObject("example"), + }, + }); + expect(referenceStore.size).toBe(0); + }); + + it("returns function result when calling a method", async () => { + const commands: BatchCommand[] = [ + { method: "get", propertyName: "getTestObject" }, + { method: "apply", args: ["arg"] }, + ]; + + const response = await executeBatchCommands(target, commands, referenceStore); + expect(response).toEqual({ + status: "success", + result: { + type: "reference", + referenceId: 1, + objectType: "TestObject", + }, + }); + expect(referenceStore.size).toBe(1); + expect(referenceStore.get(1)).toEqual(new TestObject("arg")); + }); + + it("returns function result when fetch and call is separate", async () => { + const fetchCommands: BatchCommand[] = [{ method: "get", propertyName: "getPropString" }]; + const callCommands: BatchCommand[] = [{ method: "apply", args: [] }]; + + const response = await executeBatchCommands(target, fetchCommands, referenceStore); + expect(response).toEqual({ + status: "success", + result: { + type: "reference", + referenceId: 1, + objectType: "Function", + }, + }); + expect(referenceStore.size).toBe(1); + + if (response.status === "error" || response.result.type !== "reference") { + throw new Error("Unexpected response"); + } + + const functionRefId = response.result.referenceId; + const fun = referenceStore.get(functionRefId); + const callResponse = await executeBatchCommands(fun!, callCommands, referenceStore); + + expect(callResponse).toEqual({ + status: "success", + result: { + type: "value", + value: "test", + }, + }); + }); + + it("calls method fetched using symbol property", async () => { + const commands: BatchCommand[] = [ + { method: "get", propertySymbol: serializeSymbol(Symbol.dispose) }, + { method: "apply", args: [] }, + ]; + + const response = await executeBatchCommands(target, commands, referenceStore); + expect(response).toEqual({ + status: "success", + result: { + type: "value", + value: undefined, + }, + }); + expect(target.dispose).toHaveBeenCalled(); + }); +}); + +class TestTarget { + dispose = jest.fn(); + + propString: string = "test"; + propNumber: number = 42; + propBoolean: boolean = true; + propNull: null = null; + propUndefined: undefined = undefined; + propObject = new TestObject("example"); + + [Symbol.dispose] = this.dispose; + + get child() { + return new TestTarget(); + } + + getTestObject(name: string) { + return new TestObject(name); + } + + getPropString() { + return this.propString; + } +} + +class TestObject { + constructor(public name: string) {} + + getName() { + return this.name; + } +} diff --git a/libs/common/src/platform/services/sdk/rpc/batch-executor.ts b/libs/common/src/platform/services/sdk/rpc/batch-executor.ts new file mode 100644 index 00000000000..296268c5d54 --- /dev/null +++ b/libs/common/src/platform/services/sdk/rpc/batch-executor.ts @@ -0,0 +1,89 @@ +import { RpcError } from "./error"; +import { BatchCommand, deserializeSymbol, Response } from "./protocol"; +import { ReferenceStore } from "./reference-store"; + +const PRIMITIVE_TYPES = ["string", "number", "boolean", "undefined"]; + +/** + * Executes a batch of commands on the target object. + * + * The response depends on the return type of the last command in the batch. + + * - Return-by-value will be used if: + * - The last command returns a primitive value (string, number, boolean, null, undefined). + * - The last command was explicitly a 'transfer' command. + * - Return-by-reference will be used for everything else. + * + * @param target The target object to execute commands on. + * @param commands The array of commands to execute. + * @returns A promise that resolves to the response of the batch execution. + */ +export async function executeBatchCommands( + target: any, + commands: BatchCommand[], + referenceStore: ReferenceStore, +): Promise { + if (commands.length === 0) { + return { + status: "error", + error: new RpcError("Empty batch command list is not allowed."), + }; + } + + let currentTarget = target; + let lastResult: any; + + try { + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + + if (command.method === "get" && "propertyName" in command) { + lastResult = await currentTarget[command.propertyName]; + + if (typeof lastResult === "function") { + lastResult = lastResult.bind(currentTarget); + } + + currentTarget = lastResult; + } else if (command.method === "get" && "propertySymbol" in command) { + const symbol = deserializeSymbol(command.propertySymbol); + lastResult = await currentTarget[symbol]; + + if (typeof lastResult === "function") { + lastResult = lastResult.bind(currentTarget); + } + + currentTarget = lastResult; + } else if (command.method === "apply") { + lastResult = await currentTarget(...command.args); + currentTarget = lastResult; + } else if (command.method === "transfer") { + // For transfer, we just mark the last result as transferable. + // Actual transfer logic would depend on the RPC implementation. + lastResult = currentTarget; + break; + } else { + throw new Error(`Unsupported command method: ${(command as any).method}`); + } + } + } catch (error) { + return { status: "error", error }; + } + + if (PRIMITIVE_TYPES.includes(typeof lastResult) || lastResult === null) { + return { status: "success", result: { type: "value", value: lastResult } }; + } + + if (commands[commands.length - 1].method === "transfer") { + return { status: "success", result: { type: "value", value: lastResult } }; + } + + return { + status: "success", + result: { + type: "reference", + referenceId: referenceStore.store(lastResult), + objectType: lastResult?.constructor?.name, + }, + }; +} diff --git a/libs/common/src/platform/services/sdk/rpc/reference-store.ts b/libs/common/src/platform/services/sdk/rpc/reference-store.ts index b91304796aa..b826c2e3a23 100644 --- a/libs/common/src/platform/services/sdk/rpc/reference-store.ts +++ b/libs/common/src/platform/services/sdk/rpc/reference-store.ts @@ -4,6 +4,10 @@ export class ReferenceStore { private _store = new Map(); private _nextId = 1; + get size(): number { + return this._store.size; + } + get(id: number): T | undefined { return this._store.get(id); }