Skip to content

feat: add create actor input #976

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/actor-core/fixtures/driver-test-suite/action-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { actor, setup } from "actor-core";

interface State {
initialInput?: unknown;
onCreateInput?: unknown;
}

// Test actor that can capture input during creation
const inputActor = actor({
createState: (c, { input }): State => {
return {
initialInput: input,
onCreateInput: undefined,
};
},

onCreate: (c, { input }) => {
c.state.onCreateInput = input;
},

actions: {
getInputs: (c) => {
return {
initialInput: c.state.initialInput,
onCreateInput: c.state.onCreateInput,
};
},
},
});

export const app = setup({
actors: { inputActor },
});

export type App = typeof app;
14 changes: 13 additions & 1 deletion packages/actor-core/src/actor/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export const ActorConfigSchema = z
},
);

export interface OnCreateOptions {
input?: unknown;
}

export interface CreateStateOptions {
input?: unknown;
}

export interface OnConnectOptions<CP> {
/**
* The request object associated with the connection.
Expand All @@ -92,6 +100,7 @@ type CreateState<S, CP, CS, V> =
| {
createState: (
c: ActorContext<undefined, undefined, undefined, undefined>,
opts: CreateStateOptions,
) => S | Promise<S>;
}
| Record<never, never>;
Expand Down Expand Up @@ -149,7 +158,10 @@ interface BaseActorConfig<S, CP, CS, V, R extends Actions<S, CP, CS, V>> {
* Use this hook to initialize your actor's state.
* This is called before any other lifecycle hooks.
*/
onCreate?: (c: ActorContext<S, CP, CS, V>) => void | Promise<void>;
onCreate?: (
c: ActorContext<S, CP, CS, V>,
opts: OnCreateOptions,
) => void | Promise<void>;

/**
* Called when the actor is started and ready to receive connections and action.
Expand Down
2 changes: 2 additions & 0 deletions packages/actor-core/src/actor/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface ActorDriver {
//load(): Promise<LoadOutput>;
getContext(actorId: string): unknown;

readInput(actorId: string): Promise<unknown | undefined>;

readPersistedData(actorId: string): Promise<unknown | undefined>;
writePersistedData(actorId: string, unknown: unknown): Promise<void>;

Expand Down
12 changes: 8 additions & 4 deletions packages/actor-core/src/actor/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,9 +498,7 @@ export class ActorInstance<S, CP, CS, V> {
} else {
logger().info("actor creating");

if (this.#config.onCreate) {
await this.#config.onCreate(this.actorContext);
}
const input = await this.#actorDriver.readInput(this.#actorId);

// Initialize actor state
let stateData: unknown = undefined;
Expand All @@ -518,6 +516,7 @@ export class ActorInstance<S, CP, CS, V> {
undefined,
undefined
>,
{ input },
);
} else if ("state" in this.#config) {
stateData = structuredClone(this.#config.state);
Expand All @@ -539,6 +538,11 @@ export class ActorInstance<S, CP, CS, V> {
await this.#actorDriver.writePersistedData(this.#actorId, persist);

this.#setPersist(persist);

// Notify creation
if (this.#config.onCreate) {
await this.#config.onCreate(this.actorContext, { input });
}
}
}

Expand Down Expand Up @@ -846,7 +850,7 @@ export class ActorInstance<S, CP, CS, V> {

// Prevent calling private or reserved methods
if (!(actionName in this.#config.actions)) {
logger().warn("action does not exist", { actionName });
logger().warn("action does not exist", { actionName });
throw new errors.ActionNotFound();
}

Expand Down
42 changes: 24 additions & 18 deletions packages/actor-core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export interface ActorAccessor<AD extends AnyActorDefinition> {
* @param {GetOptions} [opts] - Options for getting the actor.
* @returns {ActorHandle<AD>} - A handle to the actor.
*/
getOrCreate(key?: string | string[], opts?: GetOptions): ActorHandle<AD>;
getOrCreate(
key?: string | string[],
opts?: GetOrCreateOptions,
): ActorHandle<AD>;

/**
* Gets a stateless handle to an actor by its ID.
Expand All @@ -63,7 +66,7 @@ export interface ActorAccessor<AD extends AnyActorDefinition> {
* @returns {Promise<ActorHandle<AD>>} - A promise that resolves to a handle to the actor.
*/
create(
key: string | string[],
key?: string | string[],
opts?: CreateOptions,
): Promise<ActorHandle<AD>>;
}
Expand Down Expand Up @@ -104,9 +107,11 @@ export interface GetOptions extends QueryOptions {}
* @typedef {QueryOptions} GetOrCreateOptions
* @property {string} [createInRegion] - Region to create the actor in if it doesn't exist.
*/
export interface GetOptions extends QueryOptions {
export interface GetOrCreateOptions extends QueryOptions {
/** Region to create the actor in if it doesn't exist. */
createInRegion?: string;
/** Input data to pass to the actor. */
createWithInput?: unknown;
}

/**
Expand All @@ -117,6 +122,8 @@ export interface GetOptions extends QueryOptions {
export interface CreateOptions extends QueryOptions {
/** The region to create the actor in. */
region?: string;
/** Input data to pass to the actor. */
input?: unknown;
}

/**
Expand Down Expand Up @@ -258,7 +265,7 @@ export class ClientRaw {
getOrCreate<AD extends AnyActorDefinition>(
name: string,
key?: string | string[],
opts?: GetOptions,
opts?: GetOrCreateOptions,
): ActorHandle<AD> {
// Convert string to array of strings
const keyArray: string[] = typeof key === "string" ? [key] : key || [];
Expand All @@ -274,6 +281,7 @@ export class ClientRaw {
getOrCreateForKey: {
name,
key: keyArray,
input: opts?.createWithInput,
region: opts?.createInRegion,
},
};
Expand All @@ -299,31 +307,29 @@ export class ClientRaw {
*/
async create<AD extends AnyActorDefinition>(
name: string,
key: string | string[],
opts: CreateOptions = {},
key?: string | string[],
opts?: CreateOptions,
): Promise<ActorHandle<AD>> {
// Convert string to array of strings
const keyArray: string[] = typeof key === "string" ? [key] : key;
const keyArray: string[] = typeof key === "string" ? [key] : key || [];

// Build create config
const create = {
...opts,
// Do these last to override `opts`
name,
key: keyArray,
};
const createQuery = {
create: {
...opts,
// Do these last to override `opts`
name,
key: keyArray,
},
} satisfies ActorQuery;

logger().debug("create actor handle", {
name,
key: keyArray,
parameters: opts?.params,
create,
create: createQuery.create,
});

// Create the actor
const createQuery = {
create,
} satisfies ActorQuery;
const actorId = await resolveActorId(
this.#managerEndpoint,
createQuery,
Expand Down
5 changes: 5 additions & 0 deletions packages/actor-core/src/driver-test-suite/test-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type { App as VarsApp } from "../../fixtures/driver-test-suite/vars";
export type { App as ConnStateApp } from "../../fixtures/driver-test-suite/conn-state";
export type { App as MetadataApp } from "../../fixtures/driver-test-suite/metadata";
export type { App as ErrorHandlingApp } from "../../fixtures/driver-test-suite/error-handling";
export type { App as ActionInputsApp } from "../../fixtures/driver-test-suite/action-inputs";

export const COUNTER_APP_PATH = resolve(
__dirname,
Expand Down Expand Up @@ -50,4 +51,8 @@ export const METADATA_APP_PATH = resolve(
export const ERROR_HANDLING_APP_PATH = resolve(
__dirname,
"../../fixtures/driver-test-suite/error-handling.ts",
);
export const ACTION_INPUTS_APP_PATH = resolve(
__dirname,
"../../fixtures/driver-test-suite/action-inputs.ts",
);
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, test, expect, vi } from "vitest";
import { setupDriverTest } from "../utils";
import { ActorError } from "@/client/mod";
import { COUNTER_APP_PATH, type CounterApp } from "../test-apps";
import {
COUNTER_APP_PATH,
ACTION_INPUTS_APP_PATH,
type CounterApp,
type ActionInputsApp,
} from "../test-apps";
import { DriverTestConfig } from "../mod";

export function runManagerDriverTests(driverTestConfig: DriverTestConfig) {
Expand Down Expand Up @@ -135,6 +140,96 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) {
expect(count).toBe(10);
});

test("passes input to actor during creation", async (c) => {
const { client } = await setupDriverTest<ActionInputsApp>(
c,
driverTestConfig,
ACTION_INPUTS_APP_PATH,
);

// Test data to pass as input
const testInput = {
name: "test-actor",
value: 42,
nested: { foo: "bar" },
};

// Create actor with input
const actor = await client.inputActor.create(undefined, {
input: testInput,
});

// Verify both createState and onCreate received the input
const inputs = await actor.getInputs();

// Input should be available in createState
expect(inputs.initialInput).toEqual(testInput);

// Input should also be available in onCreate lifecycle hook
expect(inputs.onCreateInput).toEqual(testInput);
});

test("input is undefined when not provided", async (c) => {
const { client } = await setupDriverTest<ActionInputsApp>(
c,
driverTestConfig,
ACTION_INPUTS_APP_PATH,
);

// Create actor without providing input
const actor = await client.inputActor.create();

// Get inputs and verify they're undefined
const inputs = await actor.getInputs();

// Should be undefined in createState
expect(inputs.initialInput).toBeUndefined();

// Should be undefined in onCreate lifecycle hook too
expect(inputs.onCreateInput).toBeUndefined();
});

test("getOrCreate passes input to actor during creation", async (c) => {
const { client } = await setupDriverTest<ActionInputsApp>(
c,
driverTestConfig,
ACTION_INPUTS_APP_PATH,
);

// Create a unique key for this test
const uniqueKey = [`input-test-${crypto.randomUUID()}`];

// Test data to pass as input
const testInput = {
name: "getorcreate-test",
value: 100,
nested: { baz: "qux" },
};

// Use getOrCreate with input
const actor = client.inputActor.getOrCreate(uniqueKey, {
createWithInput: testInput,
});

// Verify both createState and onCreate received the input
const inputs = await actor.getInputs();

// Input should be available in createState
expect(inputs.initialInput).toEqual(testInput);

// Input should also be available in onCreate lifecycle hook
expect(inputs.onCreateInput).toEqual(testInput);

// Verify that calling getOrCreate again with the same key
// returns the existing actor and doesn't create a new one
const existingActor = client.inputActor.getOrCreate(uniqueKey);
const existingInputs = await existingActor.getInputs();

// Should still have the original inputs
expect(existingInputs.initialInput).toEqual(testInput);
expect(existingInputs.onCreateInput).toEqual(testInput);
});

// TODO: Correctly test region for each provider
//test("creates and retrieves actors with region", async (c) => {
// const { client } = await setupDriverTest<CounterApp>(c,
Expand Down
2 changes: 2 additions & 0 deletions packages/actor-core/src/manager/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ export interface GetOrCreateWithKeyInput<E extends Env = any> {
c?: HonoContext<E>;
name: string;
key: ActorKey;
input?: unknown;
region?: string;
}

export interface CreateInput<E extends Env = any> {
c?: HonoContext<E>;
name: string;
key: ActorKey;
input?: unknown;
region?: string;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/actor-core/src/manager/protocol/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
export const CreateRequestSchema = z.object({
name: z.string(),
key: ActorKeySchema,
input: z.unknown().optional(),
region: z.string().optional(),
});

Expand All @@ -24,6 +25,7 @@ export const GetForKeyRequestSchema = z.object({
export const GetOrCreateRequestSchema = z.object({
name: z.string(),
key: ActorKeySchema,
input: z.unknown().optional(),
region: z.string().optional(),
});

Expand Down
Loading
Loading