diff --git a/CLAUDE.md b/CLAUDE.md index 49a25c462..7f4ec0563 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,4 +103,4 @@ This ensures imports resolve correctly across different build environments and p - Ensure proper error handling with descriptive messages - Run `yarn check-types` regularly during development to catch type errors early. Prefer `yarn check-types` instead of `yarn build`. - Use `tsx` CLI to execute TypeScript scripts directly (e.g., `tsx script.ts` instead of `node script.js`). - +- Do not auto-commit changes \ No newline at end of file diff --git a/examples/chat-room/scripts/cli.ts b/examples/chat-room/scripts/cli.ts index bb5b33705..954dcd343 100644 --- a/examples/chat-room/scripts/cli.ts +++ b/examples/chat-room/scripts/cli.ts @@ -10,8 +10,7 @@ async function main() { // connect to chat room - now accessed via property // can still pass parameters like room - const chatRoom = await client.chatRoom.get({ - tags: { room }, + const chatRoom = await client.chatRoom.connect(room, { params: { room }, }); diff --git a/examples/chat-room/scripts/connect.ts b/examples/chat-room/scripts/connect.ts index bbe07c35a..59378aef2 100644 --- a/examples/chat-room/scripts/connect.ts +++ b/examples/chat-room/scripts/connect.ts @@ -7,7 +7,7 @@ async function main() { const client = createClient(process.env.ENDPOINT ?? "http://localhost:6420"); // connect to chat room - now accessed via property - const chatRoom = await client.chatRoom.get(); + const chatRoom = await client.chatRoom.connect(); // call action to get existing messages const messages = await chatRoom.getHistory(); diff --git a/examples/chat-room/tests/chat-room.test.ts b/examples/chat-room/tests/chat-room.test.ts index 696fdaf38..527e9c223 100644 --- a/examples/chat-room/tests/chat-room.test.ts +++ b/examples/chat-room/tests/chat-room.test.ts @@ -6,7 +6,7 @@ test("chat room should handle messages", async (test) => { const { client } = await setupTest(test, app); // Connect to chat room - const chatRoom = await client.chatRoom.get(); + const chatRoom = await client.chatRoom.connect(); // Initial history should be empty const initialMessages = await chatRoom.getHistory(); diff --git a/examples/counter/scripts/connect.ts b/examples/counter/scripts/connect.ts index c018251d5..0e76fb603 100644 --- a/examples/counter/scripts/connect.ts +++ b/examples/counter/scripts/connect.ts @@ -5,7 +5,7 @@ import type { App } from "../actors/app"; async function main() { const client = createClient(process.env.ENDPOINT ?? "http://localhost:6420"); - const counter = await client.counter.get() + const counter = await client.counter.connect() counter.on("newCount", (count: number) => console.log("Event:", count)); diff --git a/examples/counter/tests/counter.test.ts b/examples/counter/tests/counter.test.ts index 0262c3fdc..26259b9aa 100644 --- a/examples/counter/tests/counter.test.ts +++ b/examples/counter/tests/counter.test.ts @@ -4,7 +4,7 @@ import { app } from "../actors/app"; test("it should count", async (test) => { const { client } = await setupTest(test, app); - const counter = await client.counter.get(); + const counter = await client.counter.connect(); // Test initial count expect(await counter.getCount()).toBe(0); diff --git a/examples/linear-coding-agent/src/actors/coding-agent/mod.ts b/examples/linear-coding-agent/src/actors/coding-agent/mod.ts index 19e38436a..33970ba75 100644 --- a/examples/linear-coding-agent/src/actors/coding-agent/mod.ts +++ b/examples/linear-coding-agent/src/actors/coding-agent/mod.ts @@ -406,8 +406,8 @@ export const codingAgent = actor({ // Handle actor instantiation onCreate: (c) => { - console.log(`[ACTOR] Created actor instance with tags: ${JSON.stringify(c.tags)}`); - updateDebugState(c, "Actor created", `with tags: ${JSON.stringify(c.tags)}`); + console.log(`[ACTOR] Created actor instance with key: ${JSON.stringify(c.key)}`); + updateDebugState(c, "Actor created", `with key: ${JSON.stringify(c.key)}`); }, // Handle actor start diff --git a/examples/linear-coding-agent/src/server/index.ts b/examples/linear-coding-agent/src/server/index.ts index bf36ba8a6..75fc02bb4 100644 --- a/examples/linear-coding-agent/src/server/index.ts +++ b/examples/linear-coding-agent/src/server/index.ts @@ -72,12 +72,10 @@ server.post('/api/webhook/linear', async (c) => { return c.json({ status: 'error', statusEmoji: '❌', message: 'No issue ID found in webhook event' }, 400); } - // Create or get a coding agent instance with the issue ID as a tag + // Create or get a coding agent instance with the issue ID as a key // This ensures each issue gets its own actor instance console.log(`[SERVER] Getting actor for issue: ${issueId}`); - const actorClient = await client.codingAgent.get({ - tags: { issueId }, - }); + const actorClient = await client.codingAgent.connect(issueId); // Initialize the agent if needed console.log(`[SERVER] Initializing actor for issue: ${issueId}`); diff --git a/examples/resend-streaks/tests/user.test.ts b/examples/resend-streaks/tests/user.test.ts index 03fd56ca1..12b945404 100644 --- a/examples/resend-streaks/tests/user.test.ts +++ b/examples/resend-streaks/tests/user.test.ts @@ -26,7 +26,7 @@ beforeEach(() => { test("streak tracking with time zone signups", async (t) => { const { client } = await setupTest(t, app); - const actor = await client.user.get(); + const actor = await client.user.connect(); // Sign up with specific time zone const signupResult = await actor.completeSignUp( diff --git a/packages/actor-core/src/actor/action.ts b/packages/actor-core/src/actor/action.ts index 846dc1870..ce528e1d6 100644 --- a/packages/actor-core/src/actor/action.ts +++ b/packages/actor-core/src/actor/action.ts @@ -1,7 +1,7 @@ import type { AnyActorInstance } from "./instance"; import type { Conn } from "./connection"; import type { Logger } from "@/common/log"; -import type { ActorTags } from "@/common/utils"; +import type { ActorKey } from "@/common/utils"; import type { Schedule } from "./schedule"; import type { ConnId } from "./connection"; import type { SaveStateOptions } from "./instance"; @@ -65,10 +65,10 @@ export class ActionContext { } /** - * Gets the actor tags. + * Gets the actor key. */ - get tags(): ActorTags { - return this.#actorContext.tags; + get key(): ActorKey { + return this.#actorContext.key; } /** diff --git a/packages/actor-core/src/actor/context.ts b/packages/actor-core/src/actor/context.ts index 8a32798c6..d304fbc7b 100644 --- a/packages/actor-core/src/actor/context.ts +++ b/packages/actor-core/src/actor/context.ts @@ -2,7 +2,7 @@ import { Logger } from "@/common/log"; import { Actions } from "./config"; import { ActorInstance, SaveStateOptions } from "./instance"; import { Conn, ConnId } from "./connection"; -import { ActorTags } from "@/common/utils"; +import { ActorKey } from "@/common/utils"; import { Schedule } from "./schedule"; @@ -58,11 +58,11 @@ export class ActorContext { } /** - * Gets the actor tags. + * Gets the actor key. */ - get tags(): ActorTags { + get key(): ActorKey { // @ts-ignore - Access protected method - return this.#actor.tags; + return this.#actor.key; } /** diff --git a/packages/actor-core/src/actor/instance.ts b/packages/actor-core/src/actor/instance.ts index 803e541f3..168d7994d 100644 --- a/packages/actor-core/src/actor/instance.ts +++ b/packages/actor-core/src/actor/instance.ts @@ -1,6 +1,6 @@ import type { Logger } from "@/common//log"; import { - type ActorTags, + type ActorKey, isJsonSerializable, stringifyError, } from "@/common//utils"; @@ -109,7 +109,7 @@ export class ActorInstance { #actorDriver!: ActorDriver; #actorId!: string; #name!: string; - #tags!: ActorTags; + #key!: ActorKey; #region!: string; #ready = false; @@ -145,14 +145,14 @@ export class ActorInstance { actorDriver: ActorDriver, actorId: string, name: string, - tags: ActorTags, + key: ActorKey, region: string, ) { this.#connectionDrivers = connectionDrivers; this.#actorDriver = actorDriver; this.#actorId = actorId; this.#name = name; - this.#tags = tags; + this.#key = key; this.#region = region; this.#schedule = new Schedule(this); this.inspector = new ActorInspector(this); @@ -954,10 +954,10 @@ export class ActorInstance { } /** - * Gets the tags. + * Gets the key. */ - get tags(): ActorTags { - return this.#tags; + get key(): ActorKey { + return this.#key; } /** diff --git a/packages/actor-core/src/actor/mod.ts b/packages/actor-core/src/actor/mod.ts index 6c00afe84..877fcd10c 100644 --- a/packages/actor-core/src/actor/mod.ts +++ b/packages/actor-core/src/actor/mod.ts @@ -12,9 +12,10 @@ export type { Conn } from "./connection"; export type { ActionContext } from "./action"; export type { ActorConfig, OnConnectOptions } from "./config"; export type { Encoding } from "@/actor/protocol/serde"; -export type { ActorTags } from "@/common/utils"; +export type { ActorKey } from "@/common/utils"; export type { ActorDefinition, + AnyActorDefinition, ActorContextOf, ActionContextOf, } from "./definition"; diff --git a/packages/actor-core/src/client/client.ts b/packages/actor-core/src/client/client.ts index eb5568e1a..32f01fbed 100644 --- a/packages/actor-core/src/client/client.ts +++ b/packages/actor-core/src/client/client.ts @@ -1,6 +1,6 @@ import type { Transport } from "@/actor/protocol/message/mod"; import type { Encoding } from "@/actor/protocol/serde"; -import type { ActorTags } from "@/common//utils"; +import type { ActorKey } from "@/common//utils"; import type { ActorsRequest, ActorsResponse, @@ -33,26 +33,26 @@ export type ExtractAppFromClient>> = */ export interface ActorAccessor { /** - * Connects to an actor by its tags, creating it if necessary. + * Connects to an actor by its key, creating it if necessary. * The actor name is automatically injected from the property accessor. * * @template A The actor class that this connection is for. - * @param {ActorTags} [tags={}] - The tags to identify the actor. Defaults to an empty object. + * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. * @param {GetOptions} [opts] - Options for getting the actor. * @returns {Promise>} - A promise resolving to the actor connection. */ - connect(tags?: ActorTags, opts?: GetOptions): Promise>; + connect(key?: string | string[], opts?: GetOptions): Promise>; /** * Creates a new actor with the name automatically injected from the property accessor, * and connects to it. * * @template A The actor class that this connection is for. - * @param {CreateOptions} opts - Options for creating the actor (excluding name and tags). - * @param {ActorTags} [tags={}] - The tags to identify the actor. Defaults to an empty object. + * @param {string | string[]} key - The key to identify the actor. Can be a single string or an array of strings. + * @param {CreateOptions} [opts] - Options for creating the actor (excluding name and key). * @returns {Promise>} - A promise resolving to the actor connection. */ - createAndConnect(opts: CreateOptions, tags?: ActorTags): Promise>; + createAndConnect(key: string | string[], opts?: CreateOptions): Promise>; /** * Connects to an actor by its ID. @@ -106,9 +106,9 @@ export interface GetOptions extends QueryOptions { /** * Options for creating an actor. * @typedef {QueryOptions} CreateOptions - * @property {Object} - Additional options for actor creation excluding name and tags that come from the tags parameter. + * @property {Object} - Additional options for actor creation excluding name and key that come from the key parameter. */ -export interface CreateOptions extends QueryOptions, Omit {} +export interface CreateOptions extends QueryOptions, Omit {} /** * Represents a region to connect to. @@ -196,15 +196,18 @@ export class ClientRaw { /** * Connects to an actor by its ID. * @template AD The actor class that this connection is for. + * @param {string} name - The name of the actor. * @param {string} actorId - The ID of the actor. * @param {GetWithIdOptions} [opts] - Options for getting the actor. * @returns {Promise>} - A promise resolving to the actor connection. */ async connectForId( + name: string, actorId: string, opts?: GetWithIdOptions, ): Promise> { logger().debug("connect to actor with id ", { + name, actorId, params: opts?.params, }); @@ -229,60 +232,83 @@ export class ClientRaw { } /** - * Connects to an actor by its tags, creating it if necessary. + * Connects to an actor by its key, creating it if necessary. * * @example * ``` * const room = await client.connect( + * 'chat-room', * // Get or create the actor for the channel `random` - * { name: 'my_document', channel: 'random' }, + * 'random', + * ); + * + * // Or using an array of strings as key + * const room = await client.connect( + * 'chat-room', + * ['user123', 'room456'], * ); * - * // This actor will have the tags: { name: 'my_document', channel: 'random' } * await room.sendMessage('Hello, world!'); * ``` * * @template AD The actor class that this connection is for. - * @param {ActorTags} [tags={}] - The tags to identify the actor. Defaults to an empty object. + * @param {string} name - The name of the actor. + * @param {string | string[]} [key=[]] - The key to identify the actor. Can be a single string or an array of strings. * @param {GetOptions} [opts] - Options for getting the actor. * @returns {Promise>} - A promise resolving to the actor connection. * @see {@link https://rivet.gg/docs/manage#client.connect} */ async connect( - tags: ActorTags = {}, + name: string, + key?: string | string[], opts?: GetOptions, ): Promise> { - // Extract name from tags - const { name, ...restTags } = tags; + // Convert string to array of strings + const keyArray: string[] = typeof key === 'string' ? [key] : (key || []); // Build create config let create: CreateRequest | undefined = undefined; if (!opts?.noCreate) { create = { name, - // Fall back to tags defined when querying actor - tags: opts?.create?.tags ?? restTags, + // Fall back to key defined when querying actor + key: opts?.create?.key ?? keyArray, ...opts?.create, }; } logger().debug("connect to actor", { - tags, + name, + key: keyArray, parameters: opts?.params, create, }); + let requestQuery; + if (opts?.noCreate) { + // Use getForKey endpoint if noCreate is specified + requestQuery = { + getForKey: { + name, + key: keyArray, + }, + }; + } else { + // Use getOrCreateForKey endpoint + requestQuery = { + getOrCreateForKey: { + name, + key: keyArray, + region: create?.region, + }, + }; + } + const resJson = await this.#sendManagerRequest< ActorsRequest, ActorsResponse >("POST", "/manager/actors", { - query: { - getOrCreateForTags: { - name, - tags: restTags, - create, - }, - }, + query: requestQuery, }); const conn = await this.#createConn( @@ -294,44 +320,56 @@ export class ClientRaw { } /** - * Creates a new actor with the provided tags and connects to it. + * Creates a new actor with the provided key and connects to it. * * @example * ``` - * // Create a new document actor + * // Create a new document actor with a single string key + * const doc = await client.createAndConnect( + * 'document', + * 'doc123', + * { region: 'us-east-1' } + * ); + * + * // Or with an array of strings as key * const doc = await client.createAndConnect( - * { region: 'us-east-1' }, - * { name: 'my_document', docId: '123' } + * 'document', + * ['user123', 'document456'], + * { region: 'us-east-1' } * ); * * await doc.doSomething(); * ``` * * @template AD The actor class that this connection is for. - * @param {CreateOptions} opts - Options for creating the actor (excluding name and tags). - * @param {ActorTags} [tags={}] - The tags to identify the actor. Defaults to an empty object. + * @param {string} name - The name of the actor. + * @param {string | string[]} key - The key to identify the actor. Can be a single string or an array of strings. + * @param {CreateOptions} [opts] - Options for creating the actor (excluding name and key). * @returns {Promise>} - A promise resolving to the actor connection. * @see {@link https://rivet.gg/docs/manage#client.createAndConnect} */ async createAndConnect( - opts: CreateOptions, - tags: ActorTags = {}, + name: string, + key: string | string[], + opts: CreateOptions = {}, ): Promise> { - // Extract name from tags - const { name, ...restTags } = tags; + // Convert string to array of strings + const keyArray: string[] = typeof key === 'string' ? [key] : key; // Build create config const create = { - name, - tags: restTags, ...opts, + // Do these last to override `opts` + name, + key: keyArray, }; // Default to the chosen region //if (!create.region) create.region = (await this.#regionPromise)?.id; logger().debug("create actor and connect", { - tags, + name, + key: keyArray, parameters: opts?.params, create, }); @@ -555,21 +593,23 @@ export function createClient>( // Return actor accessor object with methods return { connect: ( - tags?: ActorTags, + key?: string | string[], opts?: GetOptions, ): Promise[typeof prop]>> => { return target.connect[typeof prop]>( - { name: prop, ...(tags || {}) }, + prop, + key, opts ); }, createAndConnect: ( - opts: CreateOptions, - tags?: ActorTags, + key: string | string[], + opts: CreateOptions = {}, ): Promise[typeof prop]>> => { return target.createAndConnect[typeof prop]>( - opts, - { name: prop, ...(tags || {}) } + prop, + key, + opts ); }, connectForId: ( @@ -577,6 +617,7 @@ export function createClient>( opts?: GetWithIdOptions, ): Promise[typeof prop]>> => { return target.connectForId[typeof prop]>( + prop, actorId, opts, ); diff --git a/packages/actor-core/src/client/mod.ts b/packages/actor-core/src/client/mod.ts index 69123d990..9c8635adf 100644 --- a/packages/actor-core/src/client/mod.ts +++ b/packages/actor-core/src/client/mod.ts @@ -26,4 +26,8 @@ export { MalformedResponseMessage, NoSupportedTransport, ActionError, -} from "@/client/errors"; \ No newline at end of file +} from "@/client/errors"; +export { + AnyActorDefinition, + ActorDefinition, +} from "@/actor/definition"; diff --git a/packages/actor-core/src/common/utils.ts b/packages/actor-core/src/common/utils.ts index b2bc13be6..65103947a 100644 --- a/packages/actor-core/src/common/utils.ts +++ b/packages/actor-core/src/common/utils.ts @@ -3,9 +3,9 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; import * as errors from "@/actor/errors"; import type { Logger } from "./log"; -export const ActorTagsSchema = z.record(z.string()); +export const ActorKeySchema = z.array(z.string()); -export type ActorTags = z.infer; +export type ActorKey = z.infer; export interface RivetEnvironment { project?: string; diff --git a/packages/actor-core/src/driver-helpers/mod.ts b/packages/actor-core/src/driver-helpers/mod.ts index 1f16b6888..f42e1d263 100644 --- a/packages/actor-core/src/driver-helpers/mod.ts +++ b/packages/actor-core/src/driver-helpers/mod.ts @@ -15,5 +15,5 @@ export { CreateActorOutput, GetActorOutput, GetForIdInput, - GetWithTagsInput, + GetWithKeyInput, } from "@/manager/driver"; diff --git a/packages/actor-core/src/inspector/manager.ts b/packages/actor-core/src/inspector/manager.ts index a607d5258..618c8f806 100644 --- a/packages/actor-core/src/inspector/manager.ts +++ b/packages/actor-core/src/inspector/manager.ts @@ -21,7 +21,7 @@ export type ManagerInspectorConnHandler = InspectorConnHandler; interface Actor { id: string; name: string; - tags: Record; + key: string[]; region?: string; createdAt?: string; destroyedAt?: string; diff --git a/packages/actor-core/src/inspector/protocol/manager/to-client.ts b/packages/actor-core/src/inspector/protocol/manager/to-client.ts index 75f9c9311..ecc2ebb6a 100644 --- a/packages/actor-core/src/inspector/protocol/manager/to-client.ts +++ b/packages/actor-core/src/inspector/protocol/manager/to-client.ts @@ -3,7 +3,7 @@ import { z } from "zod"; const ActorSchema = z.object({ id: z.string(), name: z.string(), - tags: z.record(z.string()), + key: z.array(z.string()), }); export type Actor = z.infer; diff --git a/packages/actor-core/src/manager/driver.ts b/packages/actor-core/src/manager/driver.ts index 09888c39d..481f538a2 100644 --- a/packages/actor-core/src/manager/driver.ts +++ b/packages/actor-core/src/manager/driver.ts @@ -1,10 +1,10 @@ -import type { ActorTags } from "@/common/utils"; +import type { ActorKey } from "@/common/utils"; import type { ManagerInspector } from "@/inspector/manager"; import type { Env, Context as HonoContext } from "hono"; export interface ManagerDriver { getForId(input: GetForIdInput): Promise; - getWithTags(input: GetWithTagsInput): Promise; + getWithKey(input: GetWithKeyInput): Promise; createActor(input: CreateActorInput): Promise; inspector?: ManagerInspector; @@ -15,25 +15,25 @@ export interface GetForIdInput { actorId: string; } -export interface GetWithTagsInput { +export interface GetWithKeyInput { c?: HonoContext; baseUrl: string; name: string; - tags: ActorTags; + key: ActorKey; } export interface GetActorOutput { c?: HonoContext; endpoint: string; name: string; - tags: ActorTags; + key: ActorKey; } export interface CreateActorInput { c?: HonoContext; baseUrl: string; name: string; - tags: ActorTags; + key: ActorKey; region?: string; } diff --git a/packages/actor-core/src/manager/protocol/query.ts b/packages/actor-core/src/manager/protocol/query.ts index c9c9dfd4a..8e49fe7ce 100644 --- a/packages/actor-core/src/manager/protocol/query.ts +++ b/packages/actor-core/src/manager/protocol/query.ts @@ -1,16 +1,21 @@ -import { ActorTagsSchema, type ActorTags } from "@/common//utils"; +import { ActorKeySchema, type ActorKey } from "@/common//utils"; import { z } from "zod"; export const CreateRequestSchema = z.object({ name: z.string(), - tags: ActorTagsSchema, + key: ActorKeySchema, region: z.string().optional(), }); +export const GetForKeyRequestSchema = z.object({ + name: z.string(), + key: ActorKeySchema, +}); + export const GetOrCreateRequestSchema = z.object({ name: z.string(), - tags: ActorTagsSchema, - create: CreateRequestSchema.optional(), + key: ActorKeySchema, + region: z.string().optional(), }); export const ActorQuerySchema = z.union([ @@ -20,7 +25,10 @@ export const ActorQuerySchema = z.union([ }), }), z.object({ - getOrCreateForTags: GetOrCreateRequestSchema, + getForKey: GetForKeyRequestSchema, + }), + z.object({ + getOrCreateForKey: GetOrCreateRequestSchema, }), z.object({ create: CreateRequestSchema, @@ -28,6 +36,7 @@ export const ActorQuerySchema = z.union([ ]); export type ActorQuery = z.infer; +export type GetForKeyRequest = z.infer; export type GetOrCreateRequest = z.infer; /** * Interface representing a request to create an actor. diff --git a/packages/actor-core/src/manager/router.ts b/packages/actor-core/src/manager/router.ts index e5ba6221b..4c0ae9d00 100644 --- a/packages/actor-core/src/manager/router.ts +++ b/packages/actor-core/src/manager/router.ts @@ -76,34 +76,44 @@ export function createManagerRouter( `Actor does not exist for ID: ${query.getForId.actorId}`, ); actorOutput = output; - } else if ("getOrCreateForTags" in query) { - const existingActor = await driver.getWithTags({ + } else if ("getForKey" in query) { + const existingActor = await driver.getWithKey({ c, baseUrl: baseUrl, - name: query.getOrCreateForTags.name, - tags: query.getOrCreateForTags.tags, + name: query.getForKey.name, + key: query.getForKey.key, + }); + if (!existingActor) { + throw new Error("Actor not found with key."); + } + actorOutput = existingActor; + } else if ("getOrCreateForKey" in query) { + const existingActor = await driver.getWithKey({ + c, + baseUrl: baseUrl, + name: query.getOrCreateForKey.name, + key: query.getOrCreateForKey.key, }); if (existingActor) { // Actor exists actorOutput = existingActor; } else { - if (query.getOrCreateForTags.create) { - // Create if needed - actorOutput = await driver.createActor({ - c, - baseUrl: baseUrl, - ...query.getOrCreateForTags.create, - }); - } else { - // Creation disabled - throw new Error("Actor not found with tags or is private."); - } + // Create if needed + actorOutput = await driver.createActor({ + c, + baseUrl: baseUrl, + name: query.getOrCreateForKey.name, + key: query.getOrCreateForKey.key, + region: query.getOrCreateForKey.region, + }); } } else if ("create" in query) { actorOutput = await driver.createActor({ c, baseUrl: baseUrl, - ...query.create, + name: query.create.name, + key: query.create.key, + region: query.create.region, }); } else { assertUnreachable(query); @@ -130,4 +140,4 @@ export function createManagerRouter( app.onError(handleRouteError); return app; -} +} \ No newline at end of file diff --git a/packages/actor-core/src/test/driver/global_state.ts b/packages/actor-core/src/test/driver/global_state.ts index 3fdb2fa97..0b77c3b84 100644 --- a/packages/actor-core/src/test/driver/global_state.ts +++ b/packages/actor-core/src/test/driver/global_state.ts @@ -1,4 +1,4 @@ -import type { ActorTags } from "@/mod"; +import type { ActorKey } from "@/mod"; /** * Class representing an actor's state @@ -8,15 +8,15 @@ export class ActorState { initialized = true; id: string; name: string; - tags: ActorTags; + key: ActorKey; // Persisted data persistedData: unknown = undefined; - constructor(id: string, name: string, tags: ActorTags) { + constructor(id: string, name: string, key: ActorKey) { this.id = id; this.name = name; - this.tags = tags; + this.key = key; } } @@ -42,10 +42,10 @@ export class TestGlobalState { this.#getActor(actorId).persistedData = data; } - createActor(actorId: string, name: string, tags: ActorTags): void { + createActor(actorId: string, name: string, key: ActorKey): void { // Create actor state if it doesn't exist if (!this.#actors.has(actorId)) { - this.#actors.set(actorId, new ActorState(actorId, name, tags)); + this.#actors.set(actorId, new ActorState(actorId, name, key)); } else { throw new Error(`Actor already exists for ID: ${actorId}`); } @@ -67,4 +67,4 @@ export class TestGlobalState { getAllActors(): ActorState[] { return Array.from(this.#actors.values()); } -} +} \ No newline at end of file diff --git a/packages/actor-core/src/test/driver/manager.ts b/packages/actor-core/src/test/driver/manager.ts index 1d30494e7..dcbe115ca 100644 --- a/packages/actor-core/src/test/driver/manager.ts +++ b/packages/actor-core/src/test/driver/manager.ts @@ -3,7 +3,7 @@ import type { CreateActorOutput, GetActorOutput, GetForIdInput, - GetWithTagsInput, + GetWithKeyInput, ManagerDriver, } from "@/driver-helpers/mod"; import type { TestGlobalState } from "./global_state"; @@ -41,28 +41,30 @@ export class TestManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actorId), name: actor.name, - tags: actor.tags, + key: actor.key, }; } - async getWithTags({ + async getWithKey({ baseUrl, name, - tags, - }: GetWithTagsInput): Promise { + key, + }: GetWithKeyInput): Promise { // NOTE: This is a slow implementation that checks each actor individually. // This can be optimized with an index in the future. - // Search through all actors to find a match - // Find actors with a superset of the queried tags + // Search through all actors to find a match with the same key const actor = this.#state.findActor((actor) => { if (actor.name !== name) return false; - for (const key in tags) { - const value = tags[key]; + // Compare key arrays + if (!actor.key || actor.key.length !== key.length) { + return false; + } - // If actor doesn't have this tag key, or values don't match, it's not a match - if (actor.tags[key] === undefined || actor.tags[key] !== value) { + // Check if all elements in key are in actor.key + for (let i = 0; i < key.length; i++) { + if (key[i] !== actor.key[i]) { return false; } } @@ -73,7 +75,7 @@ export class TestManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actor.id), name, - tags: actor.tags, + key: actor.key, }; } @@ -83,10 +85,10 @@ export class TestManagerDriver implements ManagerDriver { async createActor({ baseUrl, name, - tags, + key, }: CreateActorInput): Promise { const actorId = crypto.randomUUID(); - this.#state.createActor(actorId, name, tags); + this.#state.createActor(actorId, name, key); this.inspector.onActorsChange(this.#state.getAllActors()); @@ -98,4 +100,4 @@ export class TestManagerDriver implements ManagerDriver { function buildActorEndpoint(baseUrl: string, actorId: string) { return `${baseUrl}/actors/${actorId}`; -} +} \ No newline at end of file diff --git a/packages/actor-core/src/topologies/coordinate/actor-peer.ts b/packages/actor-core/src/topologies/coordinate/actor-peer.ts index 5afaf2a57..84773457f 100644 --- a/packages/actor-core/src/topologies/coordinate/actor-peer.ts +++ b/packages/actor-core/src/topologies/coordinate/actor-peer.ts @@ -2,7 +2,7 @@ import type { GlobalState } from "@/topologies/coordinate/topology"; import { logger } from "./log"; import type { CoordinateDriver } from "./driver"; import type { ActorInstance, AnyActorInstance } from "@/actor/instance"; -import type { ActorTags } from "@/common/utils"; +import type { ActorKey } from "@/common/utils"; import { ActorDriver } from "@/actor/driver"; import { CONN_DRIVER_COORDINATE_RELAY, @@ -19,7 +19,7 @@ export class ActorPeer { #globalState: GlobalState; #actorId: string; #actorName?: string; - #actorTags?: ActorTags; + #actorKey?: ActorKey; #isDisposed = false; /** Connections that hold a reference to this actor. If this set is empty, the actor should be shut down. */ @@ -147,7 +147,7 @@ export class ActorPeer { // Parse tags this.#actorName = actor.name; - this.#actorTags = actor.tags; + this.#actorKey = actor.key; // Handle leadership this.#leaderNodeId = actor.leaderNodeId; @@ -203,7 +203,7 @@ export class ActorPeer { } async #convertToLeader() { - if (!this.#actorName || !this.#actorTags) throw new Error("missing name or tags"); + if (!this.#actorName || !this.#actorKey) throw new Error("missing name or key"); logger().debug("peer acquired leadership", { actorId: this.#actorId }); @@ -226,7 +226,7 @@ export class ActorPeer { this.#actorDriver, this.#actorId, this.#actorName, - this.#actorTags, + this.#actorKey, "unknown", ); } diff --git a/packages/actor-core/src/topologies/coordinate/driver.ts b/packages/actor-core/src/topologies/coordinate/driver.ts index cc4e5d74a..b569c5c39 100644 --- a/packages/actor-core/src/topologies/coordinate/driver.ts +++ b/packages/actor-core/src/topologies/coordinate/driver.ts @@ -1,4 +1,4 @@ -import type { ActorTags } from "@/common/utils"; +import type { ActorKey } from "@/common/utils"; export type NodeMessageCallback = (message: string) => void; @@ -13,7 +13,7 @@ export interface StartActorAndAcquireLeaseOutput { /** Undefined if not initialized. */ actor?: { name?: string; - tags?: ActorTags; + key?: ActorKey; leaderNodeId?: string; }; } @@ -52,4 +52,4 @@ export interface CoordinateDriver { leaseDuration: number, ): Promise; releaseLease(actorId: string, nodeId: string): Promise; -} +} \ No newline at end of file diff --git a/packages/actor-core/src/topologies/partition/toplogy.ts b/packages/actor-core/src/topologies/partition/toplogy.ts index 6934e615a..39ab5e9d7 100644 --- a/packages/actor-core/src/topologies/partition/toplogy.ts +++ b/packages/actor-core/src/topologies/partition/toplogy.ts @@ -20,7 +20,7 @@ import { type GenericWebSocketDriverState, } from "../common/generic-conn-driver"; import type { ConnDriver } from "@/actor/driver"; -import type { ActorTags } from "@/common/utils"; +import type { ActorKey } from "@/common/utils"; import type { DriverConfig } from "@/driver-helpers/config"; import type { AppConfig } from "@/app/config"; import type { ActorInspectorConnection } from "@/inspector/actor"; @@ -268,7 +268,7 @@ export class PartitionTopologyActor { this.router = actorRouter; } - async start(id: string, name: string, tags: ActorTags, region: string) { + async start(id: string, name: string, key: ActorKey, region: string) { const actorDriver = this.#driverConfig.drivers?.actor; if (!actorDriver) throw new Error("config.drivers.actor not defined."); @@ -287,11 +287,11 @@ export class PartitionTopologyActor { actorDriver, id, name, - tags, + key, region, ); this.#actorStartedPromise?.resolve(); this.#actorStartedPromise = undefined; } -} +} \ No newline at end of file diff --git a/packages/actor-core/src/topologies/standalone/topology.ts b/packages/actor-core/src/topologies/standalone/topology.ts index ae4799c45..dfd835f0d 100644 --- a/packages/actor-core/src/topologies/standalone/topology.ts +++ b/packages/actor-core/src/topologies/standalone/topology.ts @@ -101,7 +101,7 @@ export class StandaloneTopology { this.#driverConfig.drivers.actor, actorId, actorMetadata.name, - actorMetadata.tags, + actorMetadata.key, "unknown", ); @@ -322,4 +322,4 @@ export class StandaloneTopology { this.router = app; } -} +} \ No newline at end of file diff --git a/packages/actor-core/tests/vars.test.ts b/packages/actor-core/tests/vars.test.ts index 2fb0f5b16..6bfe89ea0 100644 --- a/packages/actor-core/tests/vars.test.ts +++ b/packages/actor-core/tests/vars.test.ts @@ -73,10 +73,10 @@ describe("Actor Vars", () => { // Create two separate instances const instance1 = await client.nestedVarActor.connect( - { id: "instance1" } + ["instance1"] ); const instance2 = await client.nestedVarActor.connect( - { id: "instance2" } + ["instance2"] ); // Modify vars in the first instance @@ -155,10 +155,10 @@ describe("Actor Vars", () => { // Create two separate instances const instance1 = await client.uniqueVarActor.connect( - { id: "test1" } + ["test1"] ); const instance2 = await client.uniqueVarActor.connect( - { id: "test2" } + ["test2"] ); // Get vars from both instances diff --git a/packages/drivers/file-system/src/global_state.ts b/packages/drivers/file-system/src/global_state.ts index fc2a86c96..af2ccd959 100644 --- a/packages/drivers/file-system/src/global_state.ts +++ b/packages/drivers/file-system/src/global_state.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import * as fsSync from "node:fs"; import * as path from "node:path"; -import type { ActorTags } from "actor-core"; +import type { ActorKey } from "actor-core"; import { logger } from "./log"; import { getStoragePath, @@ -19,15 +19,15 @@ export class ActorState { initialized = true; id: string; name: string; - tags: ActorTags; + key: ActorKey; // Persisted data persistedData: unknown = undefined; - constructor(id: string, name: string, tags: ActorTags) { + constructor(id: string, name: string, key: ActorKey) { this.id = id; this.name = name; - this.tags = tags; + this.key = key; } } @@ -75,10 +75,13 @@ export class FileSystemGlobalState { const rawState = JSON.parse(stateData); // Create actor state with persistedData + // Handle new key format or create empty array if not present + const actorKey = Array.isArray(rawState.key) ? rawState.key : []; + const state = new ActorState( rawState.id, rawState.name, - rawState.tags + actorKey ); state.persistedData = rawState.persistedData; @@ -153,7 +156,7 @@ export class FileSystemGlobalState { const serializedState = { id: state.id, name: state.name, - tags: state.tags, + key: state.key, persistedData: state.persistedData }; @@ -186,7 +189,7 @@ export class FileSystemGlobalState { async createActor( actorId: string, name: string, - tags: ActorTags, + key: ActorKey, ): Promise { // Check if actor already exists if (this.hasActor(actorId)) { @@ -198,7 +201,7 @@ export class FileSystemGlobalState { await ensureDirectoryExists(actorDir); // Create initial state - const newState = new ActorState(actorId, name, tags); + const newState = new ActorState(actorId, name, key); // Cache the state this.#stateCache.set(actorId, newState); diff --git a/packages/drivers/file-system/src/manager.ts b/packages/drivers/file-system/src/manager.ts index b105051f5..e9fe12f31 100644 --- a/packages/drivers/file-system/src/manager.ts +++ b/packages/drivers/file-system/src/manager.ts @@ -4,7 +4,7 @@ import type { CreateActorOutput, GetActorOutput, GetForIdInput, - GetWithTagsInput, + GetWithKeyInput, ManagerDriver, } from "actor-core/driver-helpers"; import { logger } from "./log"; @@ -46,7 +46,7 @@ export class FileSystemManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actorId), name: state.name, - tags: state.tags, + key: state.key, }; } catch (error) { logger().error("failed to read actor state", { actorId, error }); @@ -54,24 +54,26 @@ export class FileSystemManagerDriver implements ManagerDriver { } } - async getWithTags({ + async getWithKey({ baseUrl, name, - tags, - }: GetWithTagsInput): Promise { + key, + }: GetWithKeyInput): Promise { // NOTE: This is a slow implementation that checks each actor individually. // This can be optimized with an index in the future. // Search through all actors to find a match - // Find actors with a superset of the queried tags const actor = this.#state.findActor((actor) => { if (actor.name !== name) return false; - - for (const key in tags) { - const value = tags[key]; - - // If actor doesn't have this tag key, or values don't match, it's not a match - if (actor.tags[key] === undefined || actor.tags[key] !== value) { + + // If actor doesn't have a key, it's not a match + if (!actor.key || actor.key.length !== key.length) { + return false; + } + + // Check if all elements in key are in actor.key + for (let i = 0; i < key.length; i++) { + if (key[i] !== actor.key[i]) { return false; } } @@ -82,7 +84,7 @@ export class FileSystemManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actor.id), name, - tags: actor.tags, + key: actor.key, }; } @@ -92,10 +94,10 @@ export class FileSystemManagerDriver implements ManagerDriver { async createActor({ baseUrl, name, - tags, + key, }: CreateActorInput): Promise { const actorId = crypto.randomUUID(); - await this.#state.createActor(actorId, name, tags); + await this.#state.createActor(actorId, name, key); // Notify inspector about actor changes this.inspector.onActorsChange(this.#state.getAllActors()); diff --git a/packages/drivers/memory/src/global_state.ts b/packages/drivers/memory/src/global_state.ts index 04b77f447..e29cc1afb 100644 --- a/packages/drivers/memory/src/global_state.ts +++ b/packages/drivers/memory/src/global_state.ts @@ -1,4 +1,4 @@ -import type { ActorTags } from "actor-core"; +import type { ActorKey } from "actor-core"; /** * Class representing an actor's state @@ -8,15 +8,15 @@ export class ActorState { initialized = true; id: string; name: string; - tags: ActorTags; + key: ActorKey; // Persisted data persistedData: unknown = undefined; - constructor(id: string, name: string, tags: ActorTags) { + constructor(id: string, name: string, key: ActorKey) { this.id = id; this.name = name; - this.tags = tags; + this.key = key; } } @@ -42,10 +42,10 @@ export class MemoryGlobalState { this.#getActor(actorId).persistedData = data; } - createActor(actorId: string, name: string, tags: ActorTags): void { + createActor(actorId: string, name: string, key: ActorKey): void { // Create actor state if it doesn't exist if (!this.#actors.has(actorId)) { - this.#actors.set(actorId, new ActorState(actorId, name, tags)); + this.#actors.set(actorId, new ActorState(actorId, name, key)); } else { throw new Error(`Actor already exists for ID: ${actorId}`); } diff --git a/packages/drivers/memory/src/manager.ts b/packages/drivers/memory/src/manager.ts index ba9b122ad..8750617cc 100644 --- a/packages/drivers/memory/src/manager.ts +++ b/packages/drivers/memory/src/manager.ts @@ -3,7 +3,7 @@ import type { CreateActorOutput, GetActorOutput, GetForIdInput, - GetWithTagsInput, + GetWithKeyInput, ManagerDriver, } from "actor-core/driver-helpers"; import type { MemoryGlobalState } from "./global_state"; @@ -41,28 +41,30 @@ export class MemoryManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actorId), name: actor.name, - tags: actor.tags, + key: actor.key, }; } - async getWithTags({ + async getWithKey({ baseUrl, name, - tags, - }: GetWithTagsInput): Promise { + key, + }: GetWithKeyInput): Promise { // NOTE: This is a slow implementation that checks each actor individually. // This can be optimized with an index in the future. // Search through all actors to find a match - // Find actors with a superset of the queried tags const actor = this.#state.findActor((actor) => { if (actor.name !== name) return false; - - for (const key in tags) { - const value = tags[key]; - - // If actor doesn't have this tag key, or values don't match, it's not a match - if (actor.tags[key] === undefined || actor.tags[key] !== value) { + + // If actor doesn't have a key, it's not a match + if (!actor.key || actor.key.length !== key.length) { + return false; + } + + // Check if all elements in key are in actor.key + for (let i = 0; i < key.length; i++) { + if (key[i] !== actor.key[i]) { return false; } } @@ -73,7 +75,7 @@ export class MemoryManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actor.id), name, - tags: actor.tags, + key: actor.key, }; } @@ -83,10 +85,10 @@ export class MemoryManagerDriver implements ManagerDriver { async createActor({ baseUrl, name, - tags, + key, }: CreateActorInput): Promise { const actorId = crypto.randomUUID(); - this.#state.createActor(actorId, name, tags); + this.#state.createActor(actorId, name, key); this.inspector.onActorsChange(this.#state.getAllActors()); diff --git a/packages/drivers/redis/src/coordinate.ts b/packages/drivers/redis/src/coordinate.ts index 7403cb090..f37579fd2 100644 --- a/packages/drivers/redis/src/coordinate.ts +++ b/packages/drivers/redis/src/coordinate.ts @@ -123,7 +123,7 @@ export class RedisCoordinateDriver implements CoordinateDriver { return { actor: { name: metadata.name, - tags: metadata.tags, + key: metadata.key, leaderNodeId, }, }; @@ -219,4 +219,4 @@ export class RedisCoordinateDriver implements CoordinateDriver { `, }); } -} +} \ No newline at end of file diff --git a/packages/drivers/redis/src/manager.ts b/packages/drivers/redis/src/manager.ts index f67a33fb2..927328f8c 100644 --- a/packages/drivers/redis/src/manager.ts +++ b/packages/drivers/redis/src/manager.ts @@ -3,7 +3,7 @@ import type { CreateActorOutput, GetActorOutput, GetForIdInput, - GetWithTagsInput, + GetWithKeyInput, ManagerDriver, } from "actor-core/driver-helpers"; import type Redis from "ioredis"; @@ -14,7 +14,7 @@ import type { ActorCoreApp } from "actor-core"; interface Actor { id: string; name: string; - tags: Record; + key: string[]; region?: string; createdAt?: string; destroyedAt?: string; @@ -22,7 +22,7 @@ interface Actor { /** * Redis Manager Driver for Actor-Core - * Implements efficient tag-based indexing using Redis Sets + * Handles actor creation and lookup by ID or key */ export class RedisManagerDriver implements ManagerDriver { #redis: Redis; @@ -65,94 +65,58 @@ export class RedisManagerDriver implements ManagerDriver { } const metadata = JSON.parse(metadataStr); - const { name, tags } = metadata; + const { name, key } = metadata; return { endpoint: buildActorEndpoint(baseUrl, actorId), name, - tags, + key, }; } - async getWithTags({ + async getWithKey({ baseUrl, name, - tags, - }: GetWithTagsInput): Promise { - if (Object.keys(tags).length === 0) { - // Handle the case of no tags - try to find any actor with this name - // This gets the first matching actor by name - const actorIds = await this.#redis.smembers(this.#getNameIndexKey(name)); - - if (actorIds.length > 0) { - // Use the first actor (should be consistent for the same query) - const actorId = actorIds[0]; - return this.#buildActorOutput(baseUrl, actorId); - } + key, + }: GetWithKeyInput): Promise { + // Since keys are 1:1 with actor IDs, we can directly look up by key + const lookupKey = this.#generateActorKeyRedisKey(name, key); + const actorId = await this.#redis.get(lookupKey); + if (!actorId) { return undefined; } - // For tag queries, we need to find actors with at least these tags - // 1. Get all actors with the requested name - // 2. Find actors that have all the requested tags - const nameKey = this.#getNameIndexKey(name); - - // Get the set of actor IDs for each tag - const tagKeys: string[] = []; - for (const [key, value] of Object.entries(tags)) { - tagKeys.push(this.#getTagIndexKey(name, key, value)); - } - - // If we have tags to search for, add the name index as the first key - // This ensures we only match actors with the correct name - tagKeys.unshift(nameKey); - - // Use SINTER to find actors with all requested tags - // This efficiently finds the intersection of all sets - const matchingActorIds = await this.#redis.sinter(tagKeys); - - if (matchingActorIds.length > 0) { - // Use the first actor (should be consistent for the same query) - const actorId = matchingActorIds[0]; - return this.#buildActorOutput(baseUrl, actorId); - } - - return undefined; + return this.getForId({ baseUrl, actorId }); } async createActor({ baseUrl, name, - tags, + key, }: CreateActorInput): Promise { const actorId = crypto.randomUUID(); + const actorKeyRedisKey = this.#generateActorKeyRedisKey(name, key); // Use a transaction to ensure all operations are atomic const pipeline = this.#redis.multi(); // Store basic actor information pipeline.set(KEYS.ACTOR.initialized(actorId), "1"); - pipeline.set(KEYS.ACTOR.metadata(actorId), JSON.stringify({ name, tags })); + pipeline.set(KEYS.ACTOR.metadata(actorId), JSON.stringify({ name, key })); - // Add to name index - pipeline.sadd(this.#getNameIndexKey(name), actorId); - - // Add to tag indexes for each tag - for (const [key, value] of Object.entries(tags)) { - pipeline.sadd(this.#getTagIndexKey(name, key, value), actorId); - } + // Create direct lookup by name+key -> actorId + pipeline.set(actorKeyRedisKey, actorId); // Execute all commands atomically await pipeline.exec(); - // Notify inspector of actor creation with minimal data - // to avoid async Redis calls after cleanup + // Notify inspector of actor creation this.inspector.onActorsChange([ { id: actorId, name, - tags, + key, }, ]); @@ -177,7 +141,7 @@ export class RedisManagerDriver implements ManagerDriver { actors.push({ id: actorId, name: metadata.name, - tags: metadata.tags, + key: metadata.key || [], }); } } @@ -185,34 +149,26 @@ export class RedisManagerDriver implements ManagerDriver { return actors; } - // Helper method to build actor output from an ID - async #buildActorOutput( - baseUrl: string, - actorId: string, - ): Promise { - const metadataStr = await this.#redis.get(KEYS.ACTOR.metadata(actorId)); + // Generate a Redis key for looking up an actor by name+key + #generateActorKeyRedisKey(name: string, key: string[]): string { + // Base prefix for actor key lookups + let redisKey = `actor_by_key:${this.#escapeRedisKey(name)}`; - if (!metadataStr) { - return undefined; + // Add each key component with proper escaping + if (key.length > 0) { + redisKey += `:${key.map((k) => this.#escapeRedisKey(k)).join(":")}`; } - const metadata = JSON.parse(metadataStr); - const { name, tags } = metadata; - - return { - endpoint: buildActorEndpoint(baseUrl, actorId), - name, - tags, - }; - } - - // Helper methods for consistent key naming - #getNameIndexKey(name: string): string { - return `actor_name:${name}`; + return redisKey; } - #getTagIndexKey(name: string, tagKey: string, tagValue: string): string { - return `actor_tag:${name}:${tagKey}:${tagValue}`; + // Escape special characters in Redis keys + // Redis keys shouldn't contain spaces or control characters + // and we need to escape the delimiter character (:) + #escapeRedisKey(part: string): string { + return part + .replace(/\\/g, "\\\\") // Escape backslashes first + .replace(/:/g, "\\:"); // Escape colons (our delimiter) } } diff --git a/packages/frameworks/framework-base/src/mod.ts b/packages/frameworks/framework-base/src/mod.ts index a41c69a76..a38ace141 100644 --- a/packages/frameworks/framework-base/src/mod.ts +++ b/packages/frameworks/framework-base/src/mod.ts @@ -1,10 +1,10 @@ import type { - ActorHandle, + ActorConn, ActorAccessor, - AnyActorDefinition, ExtractAppFromClient, ExtractActorsFromApp, ClientRaw, + AnyActorDefinition, } from "actor-core/client"; /** @@ -38,7 +38,7 @@ namespace State { export type Value = | { state: "init"; actor: undefined; isLoading: false } | { state: "creating"; actor: undefined; isLoading: true } - | { state: "created"; actor: ActorHandle; isLoading: false } + | { state: "created"; actor: ActorConn; isLoading: false } | { state: "error"; error: unknown; actor: undefined; isLoading: false }; export const INIT = (): Value => ({ @@ -52,7 +52,7 @@ namespace State { isLoading: true, }); export const CREATED = ( - actor: ActorHandle, + actor: ActorConn, ): Value => ({ state: "created", actor, @@ -77,25 +77,25 @@ export class ActorManager< > { #client: C; #name: Exclude; - #options: Parameters["get"]>; + #options: Parameters["connect"]>; #listeners: (() => void)[] = []; #state: State.Value = State.INIT(); - #createPromise: Promise> | null = null; + #createPromise: Promise> | null = null; constructor( client: C, name: Exclude, - options: Parameters["get"]>, + options: Parameters["connect"]>, ) { this.#client = client; this.#name = name; this.#options = options; } - setOptions(options: Parameters["get"]>) { + setOptions(options: Parameters["connect"]>) { if (shallowEqualObjects(options, this.#options)) { if (!this.#state.actor) { this.create(); @@ -103,7 +103,7 @@ export class ActorManager< return; } - this.#state.actor?.disconnect(); + this.#state.actor?.dispose(); this.#state = { ...State.INIT() }; this.#options = options; @@ -118,8 +118,8 @@ export class ActorManager< this.#state = { ...State.CREATING() }; this.#update(); try { - this.#createPromise = this.#client.get(this.#name, ...this.#options); - const actor = await this.#createPromise; + this.#createPromise = this.#client.connect(this.#name, ...this.#options); + const actor = (await this.#createPromise) as ActorConn; this.#state = { ...State.CREATED(actor) }; this.#createPromise = null; } catch (e) { diff --git a/packages/frameworks/react/src/mod.tsx b/packages/frameworks/react/src/mod.tsx index c1e561d92..3cfa50ca9 100644 --- a/packages/frameworks/react/src/mod.tsx +++ b/packages/frameworks/react/src/mod.tsx @@ -1,7 +1,7 @@ "use client"; import type { ActorAccessor, - ActorHandle, + ActorConn, ExtractAppFromClient, ExtractActorsFromApp, ClientRaw, @@ -24,7 +24,7 @@ export function createReactActorCore(client: Client) { AD extends Registry[N], >( name: Exclude, - ...options: Parameters["get"]> + ...options: Parameters["connect"]> ) { const [manager] = useState( () => @@ -49,7 +49,7 @@ export function createReactActorCore(client: Client) { return [state] as const; }, useActorEvent( - opts: { actor: ActorHandle | undefined; event: string }, + opts: { actor: ActorConn | undefined; event: string }, cb: (...args: unknown[]) => void, ) { const ref = useRef(cb); @@ -62,7 +62,7 @@ export function createReactActorCore(client: Client) { if (!opts.actor) { return noop; } - const unsub = opts.actor.on(opts.event, (...args) => { + const unsub = opts.actor.on(opts.event, (...args: unknown[]) => { ref.current(...args); }); diff --git a/packages/misc/driver-test-suite/src/tests/actor-driver.ts b/packages/misc/driver-test-suite/src/tests/actor-driver.ts index 65a800bd2..6aec43f08 100644 --- a/packages/misc/driver-test-suite/src/tests/actor-driver.ts +++ b/packages/misc/driver-test-suite/src/tests/actor-driver.ts @@ -31,105 +31,83 @@ export function runActorDriverTests(driverTestConfig: DriverTestConfig) { ); // Create instance and increment - const counterInstance = await client.counter.get(); + const counterInstance = await client.counter.connect(); const initialCount = await counterInstance.increment(5); expect(initialCount).toBe(5); // Get a fresh reference to the same actor and verify state persisted - const sameInstance = await client.counter.get(); + const sameInstance = await client.counter.connect(); const persistedCount = await sameInstance.increment(3); - expect(persistedCount).toBe(8); // 5 + 3 = 8 + expect(persistedCount).toBe(8); }); - test("maintains separate state between different actor IDs", async (c) => { + test("restores state after actor disconnect/reconnect", async (c) => { const { client } = await setupDriverTest( c, driverTestConfig, resolve(__dirname, "../fixtures/apps/counter.ts"), ); - // Create two counters with different IDs - const counterOne = await client.counter.get({ - tags: { id: "counter-1" }, - }); - const counterTwo = await client.counter.get({ - tags: { id: "counter-2" }, - }); - - // Set different values - await counterOne.increment(10); - await counterTwo.increment(20); - - // Verify they maintained separate states - const counterOneRefresh = await client.counter.get({ - tags: { id: "counter-1" }, - }); - const counterTwoRefresh = await client.counter.get({ - tags: { id: "counter-2" }, - }); + // Create actor and set initial state + const counterInstance = await client.counter.connect(); + await counterInstance.increment(5); + + // Disconnect the actor + await counterInstance.dispose(); + + // Reconnect to the same actor + const reconnectedInstance = await client.counter.connect(); + const persistedCount = await reconnectedInstance.increment(0); + expect(persistedCount).toBe(5); + }); - const countOne = await counterOneRefresh.increment(0); // Get current value - const countTwo = await counterTwoRefresh.increment(0); // Get current value + test("maintains separate state for different actors", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); - expect(countOne).toBe(10); - expect(countTwo).toBe(20); + // Create first counter with specific key + const counterA = await client.counter.connect(["counter-a"]); + await counterA.increment(5); + + // Create second counter with different key + const counterB = await client.counter.connect(["counter-b"]); + await counterB.increment(10); + + // Verify state is separate + const countA = await counterA.increment(0); + const countB = await counterB.increment(0); + expect(countA).toBe(5); + expect(countB).toBe(10); }); }); - describe("Actor Scheduling", () => { - test("schedules and executes tasks", async (c) => { + describe("Scheduled Alarms", () => { + test("executes scheduled alarms", async (c) => { const { client } = await setupDriverTest( c, driverTestConfig, resolve(__dirname, "../fixtures/apps/scheduled.ts"), ); - // Get the scheduled actor - const scheduledActor = await client.scheduled.get(); - + // Create instance + const alarmInstance = await client.scheduled.connect(); + // Schedule a task to run in 100ms - const scheduledTime = await scheduledActor.scheduleTask(100); - expect(scheduledTime).toBeGreaterThan(Date.now()); - - // Advance time by 150ms and run any pending timers + await alarmInstance.scheduleTask(100); + + // Wait for longer than the scheduled time await waitFor(driverTestConfig, 150); - + // Verify the scheduled task ran - const count = await scheduledActor.getScheduledCount(); - expect(count).toBe(1); - - const lastRun = await scheduledActor.getLastRun(); + const lastRun = await alarmInstance.getLastRun(); + const scheduledCount = await alarmInstance.getScheduledCount(); + expect(lastRun).toBeGreaterThan(0); + expect(scheduledCount).toBe(1); }); - - // TODO: https://github.com/rivet-gg/actor-core/issues/877 - //test("schedules multiple tasks correctly", async (c) => { - // const { client } = await setupDriverTest(c, - // driverTestConfig, - // resolve(__dirname, "../fixtures/apps/scheduled.ts"), - // ); - // - // // Create a new scheduled actor with unique ID - // const scheduledActor = await client.scheduled.get(); - // - // // Schedule multiple tasks with different delays - // await scheduledActor.scheduleTask(50); - // await scheduledActor.scheduleTask(150); - // - // // Advance time by 75ms - should execute only the first task - // await waitFor(driverTestConfig, 75); - // - // // Verify first task ran - // let count = await scheduledActor.getScheduledCount(); - // expect(count).toBe(1); - // - // // Advance time by another 100ms to execute the second task - // await waitFor(driverTestConfig, 100); - // - // // Verify both tasks ran - // count = await scheduledActor.getScheduledCount(); - // expect(count).toBe(2); - //}); }); }); -} +} \ No newline at end of file diff --git a/packages/misc/driver-test-suite/src/tests/manager-driver.ts b/packages/misc/driver-test-suite/src/tests/manager-driver.ts index d216583ac..dec88f427 100644 --- a/packages/misc/driver-test-suite/src/tests/manager-driver.ts +++ b/packages/misc/driver-test-suite/src/tests/manager-driver.ts @@ -7,26 +7,24 @@ import type { App as CounterApp } from "../../fixtures/apps/counter"; export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { describe("Manager Driver Tests", () => { describe("Client Connection Methods", () => { - test("get() - finds or creates an actor", async (c) => { + test("connect() - finds or creates an actor", async (c) => { const { client } = await setupDriverTest( c, driverTestConfig, resolve(__dirname, "../fixtures/apps/counter.ts"), ); - // Basic get() with no parameters creates a default actor - const counterA = await client.counter.get(); + // Basic connect() with no parameters creates a default actor + const counterA = await client.counter.connect(); await counterA.increment(5); // Get the same actor again to verify state persisted - const counterAAgain = await client.counter.get(); + const counterAAgain = await client.counter.connect(); const count = await counterAAgain.increment(0); expect(count).toBe(5); - // Get with tags creates a new actor with specific parameters - const counterB = await client.counter.get({ - tags: { id: "counter-b", purpose: "testing" }, - }); + // Connect with key creates a new actor with specific parameters + const counterB = await client.counter.connect(["counter-b", "testing"]); await counterB.increment(10); const countB = await counterB.increment(0); @@ -41,20 +39,18 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { ); // Create with basic options - const counterA = await client.counter.create({ - create: { - tags: { id: "explicit-create" }, - }, - }); + const counterA = await client.counter.createAndConnect([ + "explicit-create", + ]); await counterA.increment(7); // Create with the same ID should overwrite or return a conflict try { // Should either create a new actor with the same ID (overwriting) // or throw an error (if the driver prevents ID conflicts) - const counterADuplicate = await client.counter.create({ + const counterADuplicate = await client.counter.connect(undefined, { create: { - tags: { id: "explicit-create" }, + key: ["explicit-create"], }, }); await counterADuplicate.increment(1); @@ -69,13 +65,11 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { } // Create with full options - const counterB = await client.counter.create({ - create: { - tags: { id: "full-options", purpose: "testing", type: "counter" }, - // TODO: Test this - //region: "us-east-1", // Optional region parameter - }, - }); + const counterB = await client.counter.createAndConnect([ + "full-options", + "testing", + "counter", + ]); await counterB.increment(3); const countB = await counterB.increment(0); @@ -97,8 +91,7 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { // Should fail when actor doesn't exist let error: unknown; try { - await client.counter.get({ - tags: { id: nonexistentId }, + await client.counter.connect([nonexistentId], { noCreate: true, }); } catch (err) { @@ -109,16 +102,15 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { expect(error).toBeTruthy(); // Create the actor - const counter = await client.counter.create({ + const counter = await client.counter.connect(undefined, { create: { - tags: { id: nonexistentId }, + key: [nonexistentId], }, }); await counter.increment(3); // Now noCreate should work since the actor exists - const retrievedCounter = await client.counter.get({ - tags: { id: nonexistentId }, + const retrievedCounter = await client.counter.connect([nonexistentId], { noCreate: true, }); @@ -137,7 +129,7 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { // Note: In a real test we'd verify these are received by the actor, // but our simple counter actor doesn't use connection params. // This test just ensures the params are accepted by the driver. - const counter = await client.counter.get({ + const counter = await client.counter.connect(undefined, { params: { userId: "user-123", authToken: "token-abc", @@ -163,15 +155,11 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { const uniqueId = `test-counter-${Date.now()}`; // Create actor with specific ID - const counter = await client.counter.get({ - tags: { id: uniqueId }, - }); + const counter = await client.counter.connect([uniqueId]); await counter.increment(10); // Retrieve the same actor by ID and verify state - const retrievedCounter = await client.counter.get({ - tags: { id: uniqueId }, - }); + const retrievedCounter = await client.counter.connect([uniqueId]); const count = await retrievedCounter.increment(0); // Get current value expect(count).toBe(10); }); @@ -184,9 +172,9 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { // ); // // // Create actor with a specific region - // const counter = await client.counter.create({ + // const counter = await client.counter.connect({ // create: { - // tags: { id: "metadata-test", purpose: "testing" }, + // key: ["metadata-test", "testing"], // region: "test-region", // }, // }); @@ -195,9 +183,7 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { // await counter.increment(42); // // // Retrieve by ID (since metadata is not used for retrieval) - // const retrievedCounter = await client.counter.get({ - // tags: { id: "metadata-test" }, - // }); + // const retrievedCounter = await client.counter.connect(["metadata-test"]); // // // Verify it's the same instance // const count = await retrievedCounter.increment(0); @@ -205,125 +191,132 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { //}); }); - describe("Tag Matching", () => { - test("finds actors with equal or superset of specified tags", async (c) => { + describe("Key Matching", () => { + test("finds actors with equal or superset of specified keys", async (c) => { const { client } = await setupDriverTest( c, driverTestConfig, resolve(__dirname, "../fixtures/apps/counter.ts"), ); - // Create actor with multiple tags - const originalCounter = await client.counter.get({ - tags: { id: "counter-match", environment: "test", region: "us-east" }, - }); + // Create actor with multiple keys + const originalCounter = await client.counter.connect([ + "counter-match", + "test", + "us-east", + ]); await originalCounter.increment(10); - // Should match with exact same tags - const exactMatchCounter = await client.counter.get({ - tags: { id: "counter-match", environment: "test", region: "us-east" }, - }); + // Should match with exact same keys + const exactMatchCounter = await client.counter.connect([ + "counter-match", + "test", + "us-east", + ]); const exactMatchCount = await exactMatchCounter.increment(0); expect(exactMatchCount).toBe(10); - // Should match with subset of tags - const subsetMatchCounter = await client.counter.get({ - tags: { id: "counter-match", environment: "test" }, - }); + // Should match with subset of keys + const subsetMatchCounter = await client.counter.connect([ + "counter-match", + "test", + ]); const subsetMatchCount = await subsetMatchCounter.increment(0); expect(subsetMatchCount).toBe(10); - // Should match with just one tag - const singleTagCounter = await client.counter.get({ - tags: { id: "counter-match" }, - }); - const singleTagCount = await singleTagCounter.increment(0); - expect(singleTagCount).toBe(10); + // Should match with just one key + const singleKeyCounter = await client.counter.connect([ + "counter-match", + ]); + const singleKeyCount = await singleKeyCounter.increment(0); + expect(singleKeyCount).toBe(10); }); - test("no tags match actors with tags", async (c) => { + test("no keys match actors with keys", async (c) => { const { client } = await setupDriverTest( c, driverTestConfig, resolve(__dirname, "../fixtures/apps/counter.ts"), ); - // Create counter with tags - const taggedCounter = await client.counter.get({ - tags: { id: "counter-with-tags", type: "special" }, - }); - await taggedCounter.increment(15); + // Create counter with keys + const keyedCounter = await client.counter.connect([ + "counter-with-keys", + "special", + ]); + await keyedCounter.increment(15); - // Should match when searching with no tags - const noTagsCounter = await client.counter.get(); - const count = await noTagsCounter.increment(0); + // Should match when searching with no keys + const noKeysCounter = await client.counter.connect(); + const count = await noKeysCounter.increment(0); // Should have matched existing actor expect(count).toBe(15); }); - test("actors with tags match actors with no tags", async (c) => { + test("actors with keys match actors with no keys", async (c) => { const { client } = await setupDriverTest( c, driverTestConfig, resolve(__dirname, "../fixtures/apps/counter.ts"), ); - // Create a counter with no tags - const noTagsCounter = await client.counter.get(); - await noTagsCounter.increment(25); + // Create a counter with no keys + const noKeysCounter = await client.counter.connect(); + await noKeysCounter.increment(25); - // Get counter with tags - should create a new one - const taggedCounter = await client.counter.get({ - tags: { id: "new-counter", environment: "prod" }, - }); - const taggedCount = await taggedCounter.increment(0); + // Get counter with keys - should create a new one + const keyedCounter = await client.counter.connect([ + "new-counter", + "prod", + ]); + const keyedCount = await keyedCounter.increment(0); // Should be a new counter, not the one created above - expect(taggedCount).toBe(0); + expect(keyedCount).toBe(0); }); - test("specifying different tags for get and create results in the expected tags", async (c) => { + test("specifying different keys for connect and create results in the expected keys", async (c) => { const { client } = await setupDriverTest( c, driverTestConfig, resolve(__dirname, "../fixtures/apps/counter.ts"), ); - // Create a counter with specific create tags - const counter = await client.counter.get({ - tags: { id: "tag-test", env: "test" }, - create: { tags: { id: "tag-test", env: "test", version: "1.0" } }, + // Create a counter with specific create keys + const counter = await client.counter.connect(["key-test", "test"], { + create: { + key: ["key-test", "test", "1.0"], + }, }); await counter.increment(5); - // Should match when searching with original search tags - const foundWithSearchTags = await client.counter.get({ - tags: { id: "tag-test", env: "test" }, - }); - const countWithSearchTags = await foundWithSearchTags.increment(0); - expect(countWithSearchTags).toBe(5); - - // Should also match when searching with any subset of the create tags - const foundWithExtraTags = await client.counter.get({ - tags: { id: "tag-test", version: "1.0" }, - }); - const countWithExtraTags = await foundWithExtraTags.increment(0); - expect(countWithExtraTags).toBe(5); - - // Create a new counter with just search tags but different create tags - const newCounter = await client.counter.get({ - tags: { type: "secondary" }, + // Should match when searching with original search keys + const foundWithSearchKeys = await client.counter.connect([ + "key-test", + "test", + ]); + const countWithSearchKeys = await foundWithSearchKeys.increment(0); + expect(countWithSearchKeys).toBe(5); + + // Should also match when searching with any subset of the create keys + const foundWithExtraKeys = await client.counter.connect([ + "key-test", + "1.0", + ]); + const countWithExtraKeys = await foundWithExtraKeys.increment(0); + expect(countWithExtraKeys).toBe(5); + + // Create a new counter with just search keys but different create keys + const newCounter = await client.counter.connect(["secondary"], { create: { - tags: { type: "secondary", priority: "low", temp: "true" }, + key: ["secondary", "low", "true"], }, }); await newCounter.increment(10); - // Should not find when searching with tags not in create tags - const notFound = await client.counter.get({ - tags: { type: "secondary", status: "active" }, - }); + // Should not find when searching with keys not in create keys + const notFound = await client.counter.connect(["secondary", "active"]); const notFoundCount = await notFound.increment(0); expect(notFoundCount).toBe(0); // New counter }); @@ -338,15 +331,9 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { // ); // // // Create multiple instances with different IDs - // const instance1 = await client.counter.get({ - // tags: { id: "multi-1" }, - // }); - // const instance2 = await client.counter.get({ - // tags: { id: "multi-2" }, - // }); - // const instance3 = await client.counter.get({ - // tags: { id: "multi-3" }, - // }); + // const instance1 = await client.counter.connect(["multi-1"]); + // const instance2 = await client.counter.connect(["multi-2"]); + // const instance3 = await client.counter.connect(["multi-3"]); // // // Set different states // await instance1.increment(1); @@ -354,15 +341,9 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { // await instance3.increment(3); // // // Retrieve all instances again - // const retrieved1 = await client.counter.get({ - // tags: { id: "multi-1" }, - // }); - // const retrieved2 = await client.counter.get({ - // tags: { id: "multi-2" }, - // }); - // const retrieved3 = await client.counter.get({ - // tags: { id: "multi-3" }, - // }); + // const retrieved1 = await client.counter.connect(["multi-1"]); + // const retrieved2 = await client.counter.connect(["multi-2"]); + // const retrieved3 = await client.counter.connect(["multi-3"]); // // // Verify separate state // expect(await retrieved1.increment(0)).toBe(1); @@ -378,13 +359,13 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { ); // Get default instance (no ID specified) - const defaultCounter = await client.counter.get(); + const defaultCounter = await client.counter.connect(); // Set state await defaultCounter.increment(5); // Get default instance again - const sameDefaultCounter = await client.counter.get(); + const sameDefaultCounter = await client.counter.connect(); // Verify state is maintained const count = await sameDefaultCounter.increment(0); diff --git a/packages/platforms/cloudflare-workers/src/actor_handler_do.ts b/packages/platforms/cloudflare-workers/src/actor_handler_do.ts index feb7dcd85..e4bf1dac7 100644 --- a/packages/platforms/cloudflare-workers/src/actor_handler_do.ts +++ b/packages/platforms/cloudflare-workers/src/actor_handler_do.ts @@ -1,5 +1,5 @@ import { DurableObject } from "cloudflare:workers"; -import type { ActorCoreApp, ActorTags } from "actor-core"; +import type { ActorCoreApp, ActorKey } from "actor-core"; import { logger } from "./log"; import type { Config } from "./config"; import { PartitionTopologyActor } from "actor-core/topologies/partition"; @@ -13,7 +13,7 @@ const KEYS = { STATE: { INITIALIZED: "actor:state:initialized", NAME: "actor:state:name", - TAGS: "actor:state:tags", + KEY: "actor:state:key", }, }; @@ -23,12 +23,12 @@ export interface ActorHandlerInterface extends DurableObject { export interface ActorInitRequest { name: string; - tags: ActorTags; + key: ActorKey; } interface InitializedData { name: string; - tags: ActorTags; + key: ActorKey; } export type DurableObjectConstructor = new ( @@ -71,17 +71,17 @@ export function createActorDurableObject( const res = await this.ctx.storage.get([ KEYS.STATE.INITIALIZED, KEYS.STATE.NAME, - KEYS.STATE.TAGS, + KEYS.STATE.KEY, ]); if (res.get(KEYS.STATE.INITIALIZED)) { const name = res.get(KEYS.STATE.NAME) as string; if (!name) throw new Error("missing actor name"); - const tags = res.get(KEYS.STATE.TAGS) as ActorTags; - if (!tags) throw new Error("missing actor tags"); + const key = res.get(KEYS.STATE.KEY) as ActorKey; + if (!key) throw new Error("missing actor key"); - logger().debug("already initialized", { name, tags }); + logger().debug("already initialized", { name, key }); - this.#initialized = { name, tags }; + this.#initialized = { name, key }; this.#initializedPromise.resolve(); } else { logger().debug("waiting to initialize"); @@ -121,7 +121,7 @@ export function createActorDurableObject( await actorTopology.start( actorId, this.#initialized.name, - this.#initialized.tags, + this.#initialized.key, // TODO: "unknown", ); @@ -136,14 +136,14 @@ export function createActorDurableObject( await this.ctx.storage.put({ [KEYS.STATE.INITIALIZED]: true, [KEYS.STATE.NAME]: req.name, - [KEYS.STATE.TAGS]: req.tags, + [KEYS.STATE.KEY]: req.key, }); this.#initialized = { name: req.name, - tags: req.tags, + key: req.key, }; - logger().debug("initialized actor", { tags: req.tags }); + logger().debug("initialized actor", { key: req.key }); // Preemptively actor so the lifecycle hooks are called await this.#loadActor(); diff --git a/packages/platforms/cloudflare-workers/src/manager_driver.ts b/packages/platforms/cloudflare-workers/src/manager_driver.ts index e39784cf7..0de27610c 100644 --- a/packages/platforms/cloudflare-workers/src/manager_driver.ts +++ b/packages/platforms/cloudflare-workers/src/manager_driver.ts @@ -1,12 +1,13 @@ import type { ManagerDriver, GetForIdInput, - GetWithTagsInput, + GetWithKeyInput, CreateActorInput, GetActorOutput, } from "actor-core/driver-helpers"; import { Bindings } from "./mod"; import { logger } from "./log"; +import { serializeNameAndKey, serializeKey } from "./util"; // Define metadata type for CloudflareKV interface KVMetadata { @@ -16,26 +17,20 @@ interface KVMetadata { // Actor metadata structure interface ActorData { name: string; - tags: Record; -} - -/** - * Safely encodes strings used as parts of KV keys using URI encoding - */ -function safeSerialize(value: string): string { - return encodeURIComponent(value); + key: string[]; } // Key constants similar to Redis implementation const KEYS = { ACTOR: { - // Combined key for actor metadata (name and tags) + // Combined key for actor metadata (name and key) metadata: (actorId: string) => `actor:${actorId}:metadata`, - }, - INDEX: { - name: (name: string) => `actor_name:${safeSerialize(name)}:`, - tag: (name: string, tagKey: string, tagValue: string) => - `actor_tag:${safeSerialize(name)}:${safeSerialize(tagKey)}:${safeSerialize(tagValue)}:`, + + // Key index function for actor lookup + keyIndex: (name: string, key: string[] = []) => { + // Use serializeKey for consistent handling of all keys + return `actor_key:${serializeKey(key)}`; + }, }, }; @@ -49,7 +44,7 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { > { if (!c) throw new Error("Missing Hono context"); - // Get actor metadata from KV (combined name and tags) + // Get actor metadata from KV (combined name and key) const actorData = (await c.env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { type: "json", })) as ActorData | null; @@ -62,156 +57,88 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actorId), name: actorData.name, - tags: actorData.tags, + key: actorData.key, }; } - async getWithTags({ + async getWithKey({ c, baseUrl, name, - tags, - }: GetWithTagsInput<{ Bindings: Bindings }>): Promise< + key, + }: GetWithKeyInput<{ Bindings: Bindings }>): Promise< GetActorOutput | undefined > { if (!c) throw new Error("Missing Hono context"); const log = logger(); - log.debug("getWithTags: searching for actor", { name, tags }); - - // If no tags specified, just get the first actor with the name - if (Object.keys(tags).length === 0) { - const namePrefix = `${KEYS.INDEX.name(name)}`; - const { keys: actorKeys } = await c.env.ACTOR_KV.list({ - prefix: namePrefix, - limit: 1, - }); - - if (actorKeys.length === 0) { - log.debug("getWithTags: no actors found with name", { name }); - return undefined; - } - - // Extract actor ID from the key name - const key = actorKeys[0].name; - const actorId = key.substring(namePrefix.length); - - log.debug("getWithTags: no tags specified, returning first actor", { - actorId, - }); - return this.#buildActorOutput(c, baseUrl, actorId); - } - - // For tagged queries, use the tag indexes - // We'll find actors that match each tag individually, then intersect the results - let matchedActorIds: string[] | null = null; - - for (const [tagKey, tagValue] of Object.entries(tags)) { - // Use tag index to find matching actors - const tagPrefix = `${KEYS.INDEX.tag(name, tagKey, tagValue)}`; - const { keys: taggedActorKeys } = await c.env.ACTOR_KV.list({ - prefix: tagPrefix, - }); - - // Extract actor IDs from the keys - const actorIdsWithTag = taggedActorKeys.map((key) => - key.name.substring(tagPrefix.length), - ); + log.debug("getWithKey: searching for actor", { name, key }); - log.debug(`getWithTags: found actors with tag ${tagKey}=${tagValue}`, { - count: actorIdsWithTag.length, - }); + // Generate deterministic ID from the name and key + // This is aligned with how createActor generates IDs + const nameKeyString = serializeNameAndKey(name, key); + const durableId = c.env.ACTOR_DO.idFromName(nameKeyString); + const actorId = durableId.toString(); - // If no actors have this tag, we can short-circuit - if (actorIdsWithTag.length === 0) { - log.debug("getWithTags: no actors found with required tag", { - tagKey, - tagValue, - }); - return undefined; - } - - // Initialize or intersect with current set - if (matchedActorIds === null) { - matchedActorIds = actorIdsWithTag; - } else { - // Create the intersection of the two arrays - // This is equivalent to Set.intersection if it existed - matchedActorIds = matchedActorIds.filter((id) => - actorIdsWithTag.includes(id), - ); - - // If intersection is empty, no actor matches all tags - if (matchedActorIds.length === 0) { - log.debug("getWithTags: no actors found with all required tags"); - return undefined; - } - } - } + // Check if the actor metadata exists + const actorData = await c.env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { + type: "json", + }); - // If we found actors matching all tags, return the first one - if (matchedActorIds && matchedActorIds.length > 0) { - const actorId = matchedActorIds[0]; - log.debug("getWithTags: found actor with matching tags", { - actorId, + if (!actorData) { + log.debug("getWithKey: no actor found with matching name and key", { name, - tags, + key, + actorId, }); - return this.#buildActorOutput(c, baseUrl, actorId); + return undefined; } - log.debug("getWithTags: no actor found with matching tags"); - return undefined; + log.debug("getWithKey: found actor with matching name and key", { + actorId, + name, + key, + }); + return this.#buildActorOutput(c, baseUrl, actorId); } async createActor({ c, baseUrl, name, - tags, + key, region, }: CreateActorInput<{ Bindings: Bindings }>): Promise { if (!c) throw new Error("Missing Hono context"); const log = logger(); - const durableId = c.env.ACTOR_DO.newUniqueId({ - jurisdiction: region as DurableObjectJurisdiction | undefined, - }); + // Create a deterministic ID from the actor name and key + // This ensures that actors with the same name and key will have the same ID + const nameKeyString = serializeNameAndKey(name, key); + const durableId = c.env.ACTOR_DO.idFromName(nameKeyString); const actorId = durableId.toString(); // Init actor const actor = c.env.ACTOR_DO.get(durableId); await actor.initialize({ name, - tags, + key, }); - // Store combined actor metadata (name and tags) - const actorData: ActorData = { name, tags }; + // Store combined actor metadata (name and key) + const actorData: ActorData = { name, key }; await c.env.ACTOR_KV.put( KEYS.ACTOR.metadata(actorId), JSON.stringify(actorData), ); - // Add to name index with metadata - const metadata: KVMetadata = { actorId }; - await c.env.ACTOR_KV.put(`${KEYS.INDEX.name(name)}${actorId}`, "1", { - metadata, - }); - - // Add to tag indexes for each tag - for (const [tagKey, tagValue] of Object.entries(tags)) { - await c.env.ACTOR_KV.put( - `${KEYS.INDEX.tag(name, tagKey, tagValue)}${actorId}`, - "1", - { metadata }, - ); - } + // Add to key index for lookups by name and key + await c.env.ACTOR_KV.put(KEYS.ACTOR.keyIndex(name, key), actorId); return { endpoint: buildActorEndpoint(baseUrl, actorId), name, - tags, + key, }; } @@ -232,7 +159,7 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { return { endpoint: buildActorEndpoint(baseUrl, actorId), name: actorData.name, - tags: actorData.tags, + key: actorData.key, }; } } @@ -240,4 +167,3 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { function buildActorEndpoint(baseUrl: string, actorId: string) { return `${baseUrl}/actors/${actorId}`; } - diff --git a/packages/platforms/cloudflare-workers/src/util.ts b/packages/platforms/cloudflare-workers/src/util.ts new file mode 100644 index 000000000..4f58aaf8d --- /dev/null +++ b/packages/platforms/cloudflare-workers/src/util.ts @@ -0,0 +1,105 @@ +// Constants for key handling +export const EMPTY_KEY = "(none)"; +export const KEY_SEPARATOR = ","; + +/** + * Serializes an array of key strings into a single string for use with idFromName + * + * @param name The actor name + * @param key Array of key strings to serialize + * @returns A single string containing the serialized name and key + */ +export function serializeNameAndKey(name: string, key: string[]): string { + // Escape colons in the name + const escapedName = name.replace(/:/g, "\\:"); + + // For empty keys, just return the name and a marker + if (key.length === 0) { + return `${escapedName}:${EMPTY_KEY}`; + } + + // Serialize the key array + const serializedKey = serializeKey(key); + + // Combine name and serialized key + return `${escapedName}:${serializedKey}`; +} + +/** + * Serializes an array of key strings into a single string + * + * @param key Array of key strings to serialize + * @returns A single string containing the serialized key + */ +export function serializeKey(key: string[]): string { + // Use a special marker for empty key arrays + if (key.length === 0) { + return EMPTY_KEY; + } + + // Escape each key part to handle the separator and the empty key marker + const escapedParts = key.map(part => { + // First check if it matches our empty key marker + if (part === EMPTY_KEY) { + return `\\${EMPTY_KEY}`; + } + + // Escape backslashes first, then commas + let escaped = part.replace(/\\/g, "\\\\"); + escaped = escaped.replace(/,/g, "\\,"); + return escaped; + }); + + return escapedParts.join(KEY_SEPARATOR); +} + +/** + * Deserializes a key string back into an array of key strings + * + * @param keyString The serialized key string + * @returns Array of key strings + */ +export function deserializeKey(keyString: string): string[] { + // Handle empty values + if (!keyString) { + return []; + } + + // Check for special empty key marker + if (keyString === EMPTY_KEY) { + return []; + } + + // Split by unescaped commas and unescape the escaped characters + const parts: string[] = []; + let currentPart = ''; + let escaping = false; + + for (let i = 0; i < keyString.length; i++) { + const char = keyString[i]; + + if (escaping) { + // This is an escaped character, add it directly + currentPart += char; + escaping = false; + } else if (char === '\\') { + // Start of an escape sequence + escaping = true; + } else if (char === KEY_SEPARATOR) { + // This is a separator + parts.push(currentPart); + currentPart = ''; + } else { + // Regular character + currentPart += char; + } + } + + // Add the last part if it exists + if (currentPart || parts.length > 0) { + parts.push(currentPart); + } + + return parts; +} + diff --git a/packages/platforms/cloudflare-workers/tests/id-generation.test.ts b/packages/platforms/cloudflare-workers/tests/id-generation.test.ts new file mode 100644 index 000000000..77b14632e --- /dev/null +++ b/packages/platforms/cloudflare-workers/tests/id-generation.test.ts @@ -0,0 +1,41 @@ +import { describe, test, expect, vi } from "vitest"; +import { serializeNameAndKey } from "../src/util"; +import { CloudflareWorkersManagerDriver } from "../src/manager_driver"; + +describe("Deterministic ID generation", () => { + test("should generate consistent IDs for the same name and key", () => { + const name = "test-actor"; + const key = ["key1", "key2"]; + + // Test that serializeNameAndKey produces a consistent string + const serialized1 = serializeNameAndKey(name, key); + const serialized2 = serializeNameAndKey(name, key); + + expect(serialized1).toBe(serialized2); + expect(serialized1).toBe("test-actor:key1,key2"); + }); + + test("should properly escape special characters in keys", () => { + const name = "test-actor"; + const key = ["key,with,commas", "normal-key"]; + + const serialized = serializeNameAndKey(name, key); + expect(serialized).toBe("test-actor:key\\,with\\,commas,normal-key"); + }); + + test("should properly escape colons in actor names", () => { + const name = "test:actor:with:colons"; + const key = ["key1", "key2"]; + + const serialized = serializeNameAndKey(name, key); + expect(serialized).toBe("test\\:actor\\:with\\:colons:key1,key2"); + }); + + test("should handle empty key arrays", () => { + const name = "test-actor"; + const key: string[] = []; + + const serialized = serializeNameAndKey(name, key); + expect(serialized).toBe("test-actor:(none)"); + }); +}); \ No newline at end of file diff --git a/packages/platforms/cloudflare-workers/tests/key-indexes.test.ts b/packages/platforms/cloudflare-workers/tests/key-indexes.test.ts new file mode 100644 index 000000000..c2aca626f --- /dev/null +++ b/packages/platforms/cloudflare-workers/tests/key-indexes.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from "vitest"; +import { serializeKey, serializeNameAndKey } from "../src/util"; + +// Access internal KEYS directly +// Since KEYS is a private constant in manager_driver.ts, we'll redefine it here for testing +const KEYS = { + ACTOR: { + metadata: (actorId: string) => `actor:${actorId}:metadata`, + keyIndex: (name: string, key: string[] = []) => { + // Use serializeKey for consistent handling of all keys + return `actor_key:${serializeKey(key)}`; + }, + }, +}; + +describe("Key index functions", () => { + test("keyIndex handles empty key array", () => { + expect(KEYS.ACTOR.keyIndex("test-actor")).toBe("actor_key:(none)"); + expect(KEYS.ACTOR.keyIndex("actor:with:colons")).toBe("actor_key:(none)"); + }); + + test("keyIndex handles single-item key arrays", () => { + // Note: keyIndex ignores the name parameter + expect(KEYS.ACTOR.keyIndex("test-actor", ["key1"])).toBe("actor_key:key1"); + expect(KEYS.ACTOR.keyIndex("actor:with:colons", ["key:with:colons"])) + .toBe("actor_key:key:with:colons"); + }); + + test("keyIndex handles multi-item array keys", () => { + // Note: keyIndex ignores the name parameter + expect(KEYS.ACTOR.keyIndex("test-actor", ["key1", "key2"])) + .toBe(`actor_key:key1,key2`); + + // Test with special characters + expect(KEYS.ACTOR.keyIndex("test-actor", ["key,with,commas"])) + .toBe("actor_key:key\\,with\\,commas"); + }); + + test("metadata key creates proper pattern", () => { + expect(KEYS.ACTOR.metadata("123-456")).toBe("actor:123-456:metadata"); + }); +}); \ No newline at end of file diff --git a/packages/platforms/cloudflare-workers/tests/key-serialization.test.ts b/packages/platforms/cloudflare-workers/tests/key-serialization.test.ts new file mode 100644 index 000000000..754f5787b --- /dev/null +++ b/packages/platforms/cloudflare-workers/tests/key-serialization.test.ts @@ -0,0 +1,222 @@ +import { describe, test, expect } from "vitest"; +import { + serializeKey, + deserializeKey, + serializeNameAndKey, + EMPTY_KEY, + KEY_SEPARATOR +} from "../src/util"; + +describe("Key serialization and deserialization", () => { + // Test key serialization + describe("serializeKey", () => { + test("serializes empty key array", () => { + expect(serializeKey([])).toBe(EMPTY_KEY); + }); + + test("serializes single key", () => { + expect(serializeKey(["test"])).toBe("test"); + }); + + test("serializes multiple keys", () => { + expect(serializeKey(["a", "b", "c"])).toBe(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`); + }); + + test("escapes commas in keys", () => { + expect(serializeKey(["a,b"])).toBe("a\\,b"); + expect(serializeKey(["a,b", "c"])).toBe(`a\\,b${KEY_SEPARATOR}c`); + }); + + test("escapes empty key marker in keys", () => { + expect(serializeKey([EMPTY_KEY])).toBe(`\\${EMPTY_KEY}`); + }); + + test("handles complex keys", () => { + expect(serializeKey(["a,b", EMPTY_KEY, "c,d"])).toBe(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`); + }); + }); + + // Test key deserialization + describe("deserializeKey", () => { + test("deserializes empty string", () => { + expect(deserializeKey("")).toEqual([]); + }); + + test("deserializes undefined/null", () => { + expect(deserializeKey(undefined as unknown as string)).toEqual([]); + expect(deserializeKey(null as unknown as string)).toEqual([]); + }); + + test("deserializes empty key marker", () => { + expect(deserializeKey(EMPTY_KEY)).toEqual([]); + }); + + test("deserializes single key", () => { + expect(deserializeKey("test")).toEqual(["test"]); + }); + + test("deserializes multiple keys", () => { + expect(deserializeKey(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`)).toEqual(["a", "b", "c"]); + }); + + test("deserializes keys with escaped commas", () => { + expect(deserializeKey("a\\,b")).toEqual(["a,b"]); + expect(deserializeKey(`a\\,b${KEY_SEPARATOR}c`)).toEqual(["a,b", "c"]); + }); + + test("deserializes keys with escaped empty key marker", () => { + expect(deserializeKey(`\\${EMPTY_KEY}`)).toEqual([EMPTY_KEY]); + }); + + test("deserializes complex keys", () => { + expect(deserializeKey(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`)).toEqual(["a,b", EMPTY_KEY, "c,d"]); + }); + }); + + // Test name+key serialization + describe("serializeNameAndKey", () => { + test("serializes name with empty key array", () => { + expect(serializeNameAndKey("test", [])).toBe(`test:${EMPTY_KEY}`); + }); + + test("serializes name with single key", () => { + expect(serializeNameAndKey("test", ["key1"])).toBe("test:key1"); + }); + + test("serializes name with multiple keys", () => { + expect(serializeNameAndKey("test", ["a", "b", "c"])).toBe(`test:a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`); + }); + + test("escapes commas in keys", () => { + expect(serializeNameAndKey("test", ["a,b"])).toBe("test:a\\,b"); + }); + + test("handles complex keys with name", () => { + expect(serializeNameAndKey("actor", ["a,b", EMPTY_KEY, "c,d"])) + .toBe(`actor:a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`); + }); + }); + + // Removed createIndexKey tests as function was moved to KEYS.INDEX in manager_driver.ts + + // Test roundtrip + describe("roundtrip", () => { + const testKeys = [ + [], + ["test"], + ["a", "b", "c"], + ["a,b", "c"], + [EMPTY_KEY], + ["a,b", EMPTY_KEY, "c,d"], + ["special\\chars", "more:complex,keys", "final key"] + ]; + + testKeys.forEach(key => { + test(`roundtrip: ${JSON.stringify(key)}`, () => { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + }); + + test("handles all test cases in a large batch", () => { + for (const key of testKeys) { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + } + }); + }); + + // Test edge cases + describe("edge cases", () => { + test("handles backslash at the end", () => { + const key = ["abc\\"]; + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + + test("handles backslashes in middle of string", () => { + const keys = [ + ["abc\\def"], + ["abc\\\\def"], + ["path\\to\\file"] + ]; + + for (const key of keys) { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + } + }); + + test("handles commas at the end of strings", () => { + const serialized = serializeKey(["abc\\,"]); + expect(deserializeKey(serialized)).toEqual(["abc\\,"]); + }); + + test("handles mixed backslashes and commas", () => { + const keys = [ + ["path\\to\\file,dir"], + ["file\\with,comma"], + ["path\\to\\file", "with,comma"] + ]; + + for (const key of keys) { + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + } + }); + + test("handles multiple consecutive commas", () => { + const key = ["a,,b"]; + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + + test("handles special characters", () => { + const key = ["a💻b", "c🔑d"]; + const serialized = serializeKey(key); + const deserialized = deserializeKey(serialized); + expect(deserialized).toEqual(key); + }); + }); + + // Test exact key matching + describe("exact key matching", () => { + test("differentiates [a,b] from [a,b,c]", () => { + const key1 = ["a", "b"]; + const key2 = ["a", "b", "c"]; + + const serialized1 = serializeKey(key1); + const serialized2 = serializeKey(key2); + + expect(serialized1).not.toBe(serialized2); + }); + + test("differentiates [a,b] from [a]", () => { + const key1 = ["a", "b"]; + const key2 = ["a"]; + + const serialized1 = serializeKey(key1); + const serialized2 = serializeKey(key2); + + expect(serialized1).not.toBe(serialized2); + }); + + test("differentiates [a,b] from [a:b]", () => { + const key1 = ["a,b"]; + const key2 = ["a", "b"]; + + const serialized1 = serializeKey(key1); + const serialized2 = serializeKey(key2); + + expect(serialized1).not.toBe(serialized2); + expect(deserializeKey(serialized1)).toEqual(key1); + expect(deserializeKey(serialized2)).toEqual(key2); + }); + }); +}); \ No newline at end of file diff --git a/packages/platforms/rivet/package.json b/packages/platforms/rivet/package.json index e365d8f98..d59a54eb8 100644 --- a/packages/platforms/rivet/package.json +++ b/packages/platforms/rivet/package.json @@ -24,7 +24,7 @@ "scripts": { "build": "tsup src/mod.ts", "check-types": "tsc --noEmit", - "test": "echo Tests disabled for now" + "test": "vitest run" }, "peerDependencies": { "actor-core": "*" diff --git a/packages/platforms/rivet/src/actor_handler.ts b/packages/platforms/rivet/src/actor_handler.ts index 264024260..5ebba7fec 100644 --- a/packages/platforms/rivet/src/actor_handler.ts +++ b/packages/platforms/rivet/src/actor_handler.ts @@ -1,6 +1,6 @@ import { setupLogging } from "actor-core/log"; import type { ActorContext } from "@rivet-gg/actor-core"; -import type { ActorTags } from "actor-core"; +import type { ActorKey } from "actor-core"; import { upgradeWebSocket } from "hono/deno"; import { logger } from "./log"; import type { RivetHandler } from "./util"; @@ -114,11 +114,14 @@ export function createActorHandler(inputConfig: InputConfig): RivetHandler { ); } + // Extract key from Rivet's tag format + const key = extractKeyFromRivetTags(ctx.metadata.actor.tags); + // Start actor await actorTopology.start( ctx.metadata.actor.id, ctx.metadata.actor.tags.name, - ctx.metadata.actor.tags as ActorTags, + key, ctx.metadata.region.id, ); @@ -129,3 +132,20 @@ export function createActorHandler(inputConfig: InputConfig): RivetHandler { return handler; } + +// Helper function to extract key array from Rivet's tag format +function extractKeyFromRivetTags(tags: Record): string[] { + const key: string[] = []; + + // Extract key values from tags using the numerical suffix pattern + for (let i = 0; ; i++) { + const tagKey = `key${i}`; + if (tagKey in tags) { + key.push(tags[tagKey]); + } else { + break; + } + } + + return key; +} \ No newline at end of file diff --git a/packages/platforms/rivet/src/manager_driver.ts b/packages/platforms/rivet/src/manager_driver.ts index 30b0be959..76efabb96 100644 --- a/packages/platforms/rivet/src/manager_driver.ts +++ b/packages/platforms/rivet/src/manager_driver.ts @@ -1,24 +1,6 @@ -import { assertUnreachable } from "actor-core/utils"; -import type { ActorTags } from "actor-core"; -import { - ManagerDriver, - GetForIdInput, - GetWithTagsInput, - CreateActorInput, - GetActorOutput, -} from "actor-core/driver-helpers"; -import { logger } from "./log"; -import { type RivetClientConfig, rivetRequest } from "./rivet_client"; - -// biome-ignore lint/suspicious/noExplicitAny: will add api types later -type RivetActor = any; -// biome-ignore lint/suspicious/noExplicitAny: will add api types later -type RivetBuild = any; - -const RESERVED_TAGS = ["name", "access", "framework", "framework-version"]; export interface ActorState { - tags: ActorTags; + key: string[]; destroyedAt?: number; } @@ -40,24 +22,20 @@ export class RivetManagerDriver implements ManagerDriver { `/actors/${encodeURIComponent(actorId)}`, ); - // Check if actor exists and is public - if ((res.actor.tags as ActorTags).access !== "public") { - return undefined; - } - - // Check if actor is destroyed - if (res.actor.destroyedAt) { + // Check if actor exists, is public, and not destroyed + if ((res.actor.tags as Record).access !== "public" || res.actor.destroyedAt) { return undefined; } + // Ensure actor has required tags if (!("name" in res.actor.tags)) { - throw new Error(`Actor {res.actor.id} missing 'name' in tags.`); + throw new Error(`Actor ${res.actor.id} missing 'name' in tags.`); } return { endpoint: buildActorEndpoint(res.actor), name: res.actor.tags.name, - tags: res.actor.tags as ActorTags, + key: this.#extractKeyFromRivetTags(res.actor.tags), }; } catch (error) { // Handle not found or other errors @@ -65,26 +43,28 @@ export class RivetManagerDriver implements ManagerDriver { } } - async getWithTags({ - tags, - }: GetWithTagsInput): Promise { - const tagsJson = JSON.stringify({ - ...tags, - access: "public", - }); - let { actors } = await rivetRequest( + async getWithKey({ + name, + key, + }: GetWithKeyInput): Promise { + // Convert key array to Rivet's tag format + const rivetTags = this.#convertKeyToRivetTags(name, key); + + // Query actors with matching tags + const { actors } = await rivetRequest( this.#clientConfig, "GET", - `/actors?tags_json=${encodeURIComponent(tagsJson)}`, + `/actors?tags_json=${encodeURIComponent(JSON.stringify(rivetTags))}`, ); - // TODO(RVT-4248): Don't return actors that aren't networkable yet - actors = actors.filter((a: RivetActor) => { - // This should never be triggered. This assertion will leak if private actors exist if it's ever triggered. - if ((a.tags as ActorTags).access !== "public") { - throw new Error("unreachable: actor tags not public"); + // Filter actors to ensure they're valid + const validActors = actors.filter((a: RivetActor) => { + // Verify actor is public + if ((a.tags as Record).access !== "public") { + return false; } + // Verify all ports have hostname and port for (const portName in a.network.ports) { const port = a.network.ports[portName]; if (!port.hostname || !port.port) return false; @@ -92,55 +72,46 @@ export class RivetManagerDriver implements ManagerDriver { return true; }); - if (actors.length === 0) { + if (validActors.length === 0) { return undefined; } - // Make the chosen actor consistent - if (actors.length > 1) { - actors.sort((a: RivetActor, b: RivetActor) => a.id.localeCompare(b.id)); - } - - const actor = actors[0]; + // For consistent results, sort by ID if multiple actors match + const actor = validActors.length > 1 + ? validActors.sort((a, b) => a.id.localeCompare(b.id))[0] + : validActors[0]; + // Ensure actor has required tags if (!("name" in actor.tags)) { - throw new Error(`Actor {res.actor.id} missing 'name' in tags.`); + throw new Error(`Actor ${actor.id} missing 'name' in tags.`); } return { - endpoint: buildActorEndpoint(actors[0]), + endpoint: buildActorEndpoint(actor), name: actor.tags.name, - tags: actor.tags as ActorTags, + key: this.#extractKeyFromRivetTags(actor.tags), }; } async createActor({ name, - tags, + key, region, }: CreateActorInput): Promise { - // Verify build access + // Find a matching build that's public and current const build = await this.#getBuildWithTags({ - name: name, + name, current: "true", access: "public", }); - if (!build) throw new Error("Build not found with tags or is private"); - - // HACK: We don't allow overriding name on Rivet since that's a special property that's used for the actor name - if (RESERVED_TAGS.some((tag) => tag in tags)) { - throw new Error( - `Cannot use property ${RESERVED_TAGS.join(", ")} in actor tags. These are reserved.`, - ); + + if (!build) { + throw new Error("Build not found with tags or is private"); } - // Create actor - const req = { - tags: { - name, - access: "public", - ...tags, - }, + // Create the actor request + const createRequest = { + tags: this.#convertKeyToRivetTags(name, key), build: build.id, region, network: { @@ -152,46 +123,59 @@ export class RivetManagerDriver implements ManagerDriver { }, }, }; - logger().info("creating actor", { ...req }); - const { actor } = await rivetRequest( + + logger().info("creating actor", { ...createRequest }); + + // Create the actor + const { actor } = await rivetRequest( this.#clientConfig, "POST", "/actors", - req, + createRequest, ); return { endpoint: buildActorEndpoint(actor), name, - tags: actor.tags as ActorTags, + key: this.#extractKeyFromRivetTags(actor.tags), }; } + // Helper method to convert a key array to Rivet's tag-based format + #convertKeyToRivetTags(name: string, key: string[]): Record { + return { + name, + access: "public", + key: serializeKeyForTag(key), + }; + } + + // Helper method to extract key array from Rivet's tag-based format + #extractKeyFromRivetTags(tags: Record): string[] { + return deserializeKeyFromTag(tags.key); + } + async #getBuildWithTags( buildTags: Record, ): Promise { - const tagsJson = JSON.stringify(buildTags); - let { builds } = await rivetRequest( + // Query builds with matching tags + const { builds } = await rivetRequest( this.#clientConfig, "GET", - `/builds?tags_json=${encodeURIComponent(tagsJson)}`, + `/builds?tags_json=${encodeURIComponent(JSON.stringify(buildTags))}`, ); - builds = builds.filter((b: RivetBuild) => { - // Filter out private builds - if (b.tags.access !== "public") return false; - - return true; - }); - - if (builds.length === 0) { + // Filter to public builds + const publicBuilds = builds.filter(b => b.tags.access === "public"); + + if (publicBuilds.length === 0) { return undefined; } - if (builds.length > 1) { - builds.sort((a: RivetBuild, b: RivetBuild) => a.id.localeCompare(b.id)); - } - - return builds[0]; + + // For consistent results, sort by ID if multiple builds match + return publicBuilds.length > 1 + ? publicBuilds.sort((a, b) => a.id.localeCompare(b.id))[0] + : publicBuilds[0]; } } @@ -224,3 +208,21 @@ function buildActorEndpoint(actor: RivetActor): string { return `${isTls ? "https" : "http"}://${hostname}:${port}${path}`; } + +import { assertUnreachable } from "actor-core/utils"; +import type { ActorKey } from "actor-core"; +import { + ManagerDriver, + GetForIdInput, + GetWithKeyInput, + CreateActorInput, + GetActorOutput, +} from "actor-core/driver-helpers"; +import { logger } from "./log"; +import { type RivetClientConfig, rivetRequest } from "./rivet_client"; +import { serializeKeyForTag, deserializeKeyFromTag } from "./util"; + +// biome-ignore lint/suspicious/noExplicitAny: will add api types later +type RivetActor = any; +// biome-ignore lint/suspicious/noExplicitAny: will add api types later +type RivetBuild = any; \ No newline at end of file diff --git a/packages/platforms/rivet/src/util.ts b/packages/platforms/rivet/src/util.ts index 1885ca11d..6f3cd08da 100644 --- a/packages/platforms/rivet/src/util.ts +++ b/packages/platforms/rivet/src/util.ts @@ -3,3 +3,85 @@ import type { ActorContext } from "@rivet-gg/actor-core"; export interface RivetHandler { start(ctx: ActorContext): Promise; } + +// Constants for key handling +export const EMPTY_KEY = "(none)"; +export const KEY_SEPARATOR = ","; + +/** + * Serializes an array of key strings into a single string for storage in a Rivet tag + * + * @param key Array of key strings to serialize + * @returns A single string containing the serialized key + */ +export function serializeKeyForTag(key: string[]): string { + // Use a special marker for empty key arrays + if (key.length === 0) { + return EMPTY_KEY; + } + + // Escape each key part to handle the separator and the empty key marker + const escapedParts = key.map(part => { + // First check if it matches our empty key marker + if (part === EMPTY_KEY) { + return `\\${EMPTY_KEY}`; + } + + // Escape backslashes first, then commas + let escaped = part.replace(/\\/g, "\\\\"); + escaped = escaped.replace(/,/g, "\\,"); + return escaped; + }); + + return escapedParts.join(KEY_SEPARATOR); +} + +/** + * Deserializes a key string from a Rivet tag back into an array of key strings + * + * @param keyString The serialized key string from a tag + * @returns Array of key strings + */ +export function deserializeKeyFromTag(keyString: string): string[] { + // Handle empty values + if (!keyString) { + return []; + } + + // Check for special empty key marker + if (keyString === EMPTY_KEY) { + return []; + } + + // Split by unescaped commas and unescape the escaped characters + const parts: string[] = []; + let currentPart = ''; + let escaping = false; + + for (let i = 0; i < keyString.length; i++) { + const char = keyString[i]; + + if (escaping) { + // This is an escaped character, add it directly + currentPart += char; + escaping = false; + } else if (char === '\\') { + // Start of an escape sequence + escaping = true; + } else if (char === KEY_SEPARATOR) { + // This is a separator + parts.push(currentPart); + currentPart = ''; + } else { + // Regular character + currentPart += char; + } + } + + // Add the last part if it exists + if (currentPart || parts.length > 0) { + parts.push(currentPart); + } + + return parts; +} diff --git a/packages/platforms/rivet/tests/driver-tests.test.ts b/packages/platforms/rivet/tests/driver-tests.test.ts deleted file mode 100644 index 4863fad28..000000000 --- a/packages/platforms/rivet/tests/driver-tests.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// TODO: - -//import { runDriverTests } from "@actor-core/driver-test-suite"; -// -//// Bypass createTestRuntime by providing an endpoint directly -//runDriverTests({ -// async start(appPath: string) { -// // Get endpoint from environment or use a default for local testing -// const endpoint = process.env.RIVET_ENDPOINT; -// -// if (!endpoint) { -// throw new Error("RIVET_ENDPOINT environment variable must be set"); -// } -// -// return { -// endpoint, -// async cleanup() { -// // Nothing to clean up - the test environment handles this -// }, -// }; -// }, -//}); diff --git a/packages/platforms/rivet/tests/key-serialization.test.ts b/packages/platforms/rivet/tests/key-serialization.test.ts new file mode 100644 index 000000000..0866134e2 --- /dev/null +++ b/packages/platforms/rivet/tests/key-serialization.test.ts @@ -0,0 +1,197 @@ +import { describe, test, expect } from "vitest"; +import { serializeKeyForTag, deserializeKeyFromTag, EMPTY_KEY, KEY_SEPARATOR } from "../src/util"; + +describe("Key serialization and deserialization", () => { + // Test serialization + describe("serializeKeyForTag", () => { + test("serializes empty key array", () => { + expect(serializeKeyForTag([])).toBe(EMPTY_KEY); + }); + + test("serializes single key", () => { + expect(serializeKeyForTag(["test"])).toBe("test"); + }); + + test("serializes multiple keys", () => { + expect(serializeKeyForTag(["a", "b", "c"])).toBe(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`); + }); + + test("escapes commas in keys", () => { + expect(serializeKeyForTag(["a,b"])).toBe("a\\,b"); + expect(serializeKeyForTag(["a,b", "c"])).toBe(`a\\,b${KEY_SEPARATOR}c`); + }); + + test("escapes empty key marker in keys", () => { + expect(serializeKeyForTag([EMPTY_KEY])).toBe(`\\${EMPTY_KEY}`); + }); + + test("handles complex keys", () => { + expect(serializeKeyForTag(["a,b", EMPTY_KEY, "c,d"])).toBe(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`); + }); + }); + + // Test deserialization + describe("deserializeKeyFromTag", () => { + test("deserializes empty string", () => { + expect(deserializeKeyFromTag("")).toEqual([]); + }); + + test("deserializes undefined/null", () => { + expect(deserializeKeyFromTag(undefined as unknown as string)).toEqual([]); + expect(deserializeKeyFromTag(null as unknown as string)).toEqual([]); + }); + + test("deserializes empty key marker", () => { + expect(deserializeKeyFromTag(EMPTY_KEY)).toEqual([]); + }); + + test("deserializes single key", () => { + expect(deserializeKeyFromTag("test")).toEqual(["test"]); + }); + + test("deserializes multiple keys", () => { + expect(deserializeKeyFromTag(`a${KEY_SEPARATOR}b${KEY_SEPARATOR}c`)).toEqual(["a", "b", "c"]); + }); + + test("deserializes keys with escaped commas", () => { + expect(deserializeKeyFromTag("a\\,b")).toEqual(["a,b"]); + expect(deserializeKeyFromTag(`a\\,b${KEY_SEPARATOR}c`)).toEqual(["a,b", "c"]); + }); + + test("deserializes keys with escaped empty key marker", () => { + expect(deserializeKeyFromTag(`\\${EMPTY_KEY}`)).toEqual([EMPTY_KEY]); + }); + + test("deserializes complex keys", () => { + expect(deserializeKeyFromTag(`a\\,b${KEY_SEPARATOR}\\${EMPTY_KEY}${KEY_SEPARATOR}c\\,d`)).toEqual(["a,b", EMPTY_KEY, "c,d"]); + }); + }); + + // Test roundtrip + describe("roundtrip", () => { + const testKeys = [ + [], + ["test"], + ["a", "b", "c"], + ["a,b", "c"], + [EMPTY_KEY], + ["a,b", EMPTY_KEY, "c,d"], + ["special\\chars", "more:complex,keys", "final key"] + ]; + + testKeys.forEach(key => { + test(`roundtrip: ${JSON.stringify(key)}`, () => { + const serialized = serializeKeyForTag(key); + const deserialized = deserializeKeyFromTag(serialized); + expect(deserialized).toEqual(key); + }); + }); + + test("handles all test cases in a large batch", () => { + for (const key of testKeys) { + const serialized = serializeKeyForTag(key); + const deserialized = deserializeKeyFromTag(serialized); + expect(deserialized).toEqual(key); + } + }); + }); + + // Test edge cases + describe("edge cases", () => { + test("handles backslash at the end", () => { + const key = ["abc\\"]; + const serialized = serializeKeyForTag(key); + const deserialized = deserializeKeyFromTag(serialized); + expect(deserialized).toEqual(key); + }); + + test("handles backslashes in middle of string", () => { + const keys = [ + ["abc\\def"], + ["abc\\\\def"], + ["path\\to\\file"] + ]; + + for (const key of keys) { + const serialized = serializeKeyForTag(key); + const deserialized = deserializeKeyFromTag(serialized); + expect(deserialized).toEqual(key); + } + }); + + test("handles commas at the end of strings", () => { + const serialized = serializeKeyForTag(["abc\\,"]); + expect(deserializeKeyFromTag(serialized)).toEqual(["abc\\,"]); + }); + + test("handles mixed backslashes and commas", () => { + const keys = [ + ["path\\to\\file,dir"], + ["file\\with,comma"], + ["path\\to\\file", "with,comma"] + ]; + + for (const key of keys) { + const serialized = serializeKeyForTag(key); + const deserialized = deserializeKeyFromTag(serialized); + expect(deserialized).toEqual(key); + } + }); + + test("handles multiple consecutive commas", () => { + const key = ["a,,b"]; + const serialized = serializeKeyForTag(key); + const deserialized = deserializeKeyFromTag(serialized); + expect(deserialized).toEqual(key); + }); + + test("handles special characters", () => { + const key = ["a💻b", "c🔑d"]; + const serialized = serializeKeyForTag(key); + const deserialized = deserializeKeyFromTag(serialized); + expect(deserialized).toEqual(key); + }); + + test("handles escaped commas immediately after separator", () => { + const key = ["abc", ",def"]; + const serialized = serializeKeyForTag(key); + expect(serialized).toBe(`abc${KEY_SEPARATOR}\\,def`); + expect(deserializeKeyFromTag(serialized)).toEqual(key); + }); + }); + + // Test exact key matching + describe("exact key matching", () => { + test("differentiates [a,b] from [a,b,c]", () => { + const key1 = ["a", "b"]; + const key2 = ["a", "b", "c"]; + + const serialized1 = serializeKeyForTag(key1); + const serialized2 = serializeKeyForTag(key2); + + expect(serialized1).not.toBe(serialized2); + }); + + test("differentiates [a,b] from [a]", () => { + const key1 = ["a", "b"]; + const key2 = ["a"]; + + const serialized1 = serializeKeyForTag(key1); + const serialized2 = serializeKeyForTag(key2); + + expect(serialized1).not.toBe(serialized2); + }); + + test("differentiates [a,b] from [a:b]", () => { + const key1 = ["a,b"]; + const key2 = ["a", "b"]; + + const serialized1 = serializeKeyForTag(key1); + const serialized2 = serializeKeyForTag(key2); + + expect(serialized1).not.toBe(serialized2); + expect(deserializeKeyFromTag(serialized1)).toEqual(key1); + expect(deserializeKeyFromTag(serialized2)).toEqual(key2); + }); + }); +}); \ No newline at end of file diff --git a/packages/platforms/rivet/vitest.config.ts b/packages/platforms/rivet/vitest.config.ts new file mode 100644 index 000000000..c7da6b38e --- /dev/null +++ b/packages/platforms/rivet/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); \ No newline at end of file