From 3ff01c61cd08aa6e185894f51ecec7c5db25c5c0 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 7 Apr 2025 16:36:10 -0700 Subject: [PATCH] test(driver-tests): add reusable tests for all drivers --- package.json | 1 + packages/actor-core/src/actor/connection.ts | 37 +- packages/actor-core/src/actor/driver.ts | 13 +- packages/actor-core/src/actor/instance.ts | 155 +++++-- packages/actor-core/src/actor/keys.ts | 16 - packages/actor-core/src/actor/persisted.ts | 43 ++ packages/actor-core/src/actor/schedule.ts | 159 +------ packages/actor-core/src/client/handle.ts | 42 +- packages/actor-core/src/common/utils.ts | 2 +- .../actor-core/src/driver-helpers/config.ts | 1 - packages/actor-core/src/driver-helpers/mod.ts | 6 +- packages/actor-core/src/test/driver/actor.ts | 59 +-- .../src/test/driver/global_state.ts | 28 +- packages/drivers/file-system/package.json | 7 +- packages/drivers/file-system/src/actor.ts | 72 +-- .../drivers/file-system/src/global_state.ts | 92 ++-- packages/drivers/file-system/src/manager.ts | 34 +- .../file-system/tests/driver-tests.test.ts | 35 ++ packages/drivers/file-system/tsconfig.json | 2 +- packages/drivers/memory/package.json | 7 +- packages/drivers/memory/src/actor.ts | 57 +-- packages/drivers/memory/src/global_state.ts | 55 +-- packages/drivers/memory/src/manager.ts | 25 +- .../drivers/memory/tests/driver-tests.test.ts | 18 + packages/drivers/memory/tsconfig.json | 2 +- packages/drivers/redis/package.json | 20 +- packages/drivers/redis/src/actor.ts | 85 ++-- packages/drivers/redis/src/coordinate.ts | 13 +- packages/drivers/redis/src/keys.ts | 6 +- packages/drivers/redis/src/manager.ts | 236 +++++++--- packages/drivers/redis/src/mod.ts | 3 + .../drivers/redis/tests/driver-tests.test.ts | 118 +++++ packages/drivers/redis/tsconfig.json | 2 +- .../fixtures/apps/counter.ts | 18 + .../fixtures/apps/scheduled.ts | 35 ++ packages/misc/driver-test-suite/package.json | 41 ++ packages/misc/driver-test-suite/src/log.ts | 7 + packages/misc/driver-test-suite/src/mod.ts | 126 +++++ .../src/tests/actor-driver.ts | 131 ++++++ .../src/tests/manager-driver.ts | 384 ++++++++++++++++ packages/misc/driver-test-suite/src/utils.ts | 29 ++ packages/misc/driver-test-suite/tsconfig.json | 9 + .../tsup.config.bundled_6lmockkaxzl.mjs | 22 + .../misc/driver-test-suite/tsup.config.ts | 4 + packages/misc/driver-test-suite/turbo.json | 4 + .../misc/driver-test-suite/vitest.config.ts | 14 + .../platforms/cloudflare-workers/package.json | 8 +- .../cloudflare-workers/src/actor_driver.ts | 41 +- .../cloudflare-workers/src/manager_driver.ts | 214 +++++++-- .../tests/driver-tests.test.ts | 176 +++++++ .../cloudflare-workers/tsconfig.json | 4 +- .../platforms/cloudflare-workers/turbo.json | 8 +- .../cloudflare-workers/vitest.config.ts | 7 + packages/platforms/rivet/package.json | 8 +- packages/platforms/rivet/public/tsconfig.json | 3 +- packages/platforms/rivet/src/actor_driver.ts | 35 +- .../rivet/tests/driver-tests.test.ts | 22 + packages/platforms/rivet/tsconfig.json | 2 +- yarn.lock | 435 +++++++++++++++++- 59 files changed, 2372 insertions(+), 866 deletions(-) delete mode 100644 packages/actor-core/src/actor/keys.ts create mode 100644 packages/actor-core/src/actor/persisted.ts create mode 100644 packages/drivers/file-system/tests/driver-tests.test.ts create mode 100644 packages/drivers/memory/tests/driver-tests.test.ts create mode 100644 packages/drivers/redis/src/mod.ts create mode 100644 packages/drivers/redis/tests/driver-tests.test.ts create mode 100644 packages/misc/driver-test-suite/fixtures/apps/counter.ts create mode 100644 packages/misc/driver-test-suite/fixtures/apps/scheduled.ts create mode 100644 packages/misc/driver-test-suite/package.json create mode 100644 packages/misc/driver-test-suite/src/log.ts create mode 100644 packages/misc/driver-test-suite/src/mod.ts create mode 100644 packages/misc/driver-test-suite/src/tests/actor-driver.ts create mode 100644 packages/misc/driver-test-suite/src/tests/manager-driver.ts create mode 100644 packages/misc/driver-test-suite/src/utils.ts create mode 100644 packages/misc/driver-test-suite/tsconfig.json create mode 100644 packages/misc/driver-test-suite/tsup.config.bundled_6lmockkaxzl.mjs create mode 100644 packages/misc/driver-test-suite/tsup.config.ts create mode 100644 packages/misc/driver-test-suite/turbo.json create mode 100644 packages/misc/driver-test-suite/vitest.config.ts create mode 100644 packages/platforms/cloudflare-workers/tests/driver-tests.test.ts create mode 100644 packages/platforms/cloudflare-workers/vitest.config.ts create mode 100644 packages/platforms/rivet/tests/driver-tests.test.ts diff --git a/package.json b/package.json index 127ab55c5..12648b8c4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev": "npx turbo watch dev", "build": "npx turbo build", "test": "npx turbo test", + "test:watch": "npx turbo watch test", "check-types": "npx turbo check-types", "fmt": "yarn biome check --write .", "dev-docs": "cd docs && yarn dlx mintlify@latest dev", diff --git a/packages/actor-core/src/actor/connection.ts b/packages/actor-core/src/actor/connection.ts index 008a9c48c..e20955fdb 100644 --- a/packages/actor-core/src/actor/connection.ts +++ b/packages/actor-core/src/actor/connection.ts @@ -1,16 +1,10 @@ -import type { - ActorInstance, - AnyActorInstance, - ExtractActorConnParams, - ExtractActorConnState, -} from "./instance"; +import type { ActorInstance } from "./instance"; import * as errors from "./errors"; import { generateSecureToken } from "./utils"; -import { CachedSerializer, Encoding } from "./protocol/serde"; -import { logger } from "./log"; -import { ConnDriver } from "./driver"; +import { CachedSerializer } from "./protocol/serde"; +import type { ConnDriver } from "./driver"; import * as messageToClient from "@/actor/protocol/message/to-client"; -import { Actions } from "./config"; +import type { PersistedConn } from "./persisted"; export function generateConnId(): string { return crypto.randomUUID(); @@ -22,29 +16,6 @@ export function generateConnToken(): string { export type ConnId = string; -/** Object representing connection that gets persisted to storage. */ -export interface PersistedConn { - // ID - i: string; - // Token - t: string; - // Connection driver - d: string; - // Connection driver state - ds: unknown; - // Parameters - p: CP; - // State - s: CS; - // Subscriptions - su: PersistedSub[]; -} - -export interface PersistedSub { - // Event name - n: string; -} - export type AnyConn = Conn; /** diff --git a/packages/actor-core/src/actor/driver.ts b/packages/actor-core/src/actor/driver.ts index a5ef891c6..238d5112f 100644 --- a/packages/actor-core/src/actor/driver.ts +++ b/packages/actor-core/src/actor/driver.ts @@ -5,21 +5,12 @@ import { AnyConn } from "./connection"; export type ConnDrivers = Record; -export type KvKey = unknown[]; -export type KvValue = unknown; - - export interface ActorDriver { //load(): Promise; getContext(actorId: string): unknown; - // HACK: Clean these up - kvGet(actorId: string, key: KvKey): Promise; - kvGetBatch(actorId: string, key: KvKey[]): Promise<(KvValue | undefined)[]>; - kvPut(actorId: string, key: KvKey, value: KvValue): Promise; - kvPutBatch(actorId: string, key: [KvKey, KvValue][]): Promise; - kvDelete(actorId: string, key: KvKey): Promise; - kvDeleteBatch(actorId: string, key: KvKey[]): Promise; + readPersistedData(actorId: string): Promise; + writePersistedData(actorId: string, unknown: unknown): Promise; // Schedule setAlarm(actor: AnyActorInstance, timestamp: number): Promise; diff --git a/packages/actor-core/src/actor/instance.ts b/packages/actor-core/src/actor/instance.ts index c9acf68d9..883d933b8 100644 --- a/packages/actor-core/src/actor/instance.ts +++ b/packages/actor-core/src/actor/instance.ts @@ -1,6 +1,9 @@ -import type { PersistedConn } from "./connection"; import type { Logger } from "@/common//log"; -import { type ActorTags, isJsonSerializable, stringifyError } from "@/common//utils"; +import { + type ActorTags, + isJsonSerializable, + stringifyError, +} from "@/common//utils"; import onChange from "on-change"; import type { ActorConfig } from "./config"; import { Conn, type ConnId } from "./connection"; @@ -9,15 +12,15 @@ import type { ConnDriver } from "./driver"; import * as errors from "./errors"; import { processMessage } from "./protocol/message/mod"; import { instanceLogger, logger } from "./log"; -import { ActionContext } from "./action"; +import type { ActionContext } from "./action"; import { Lock, deadline } from "./utils"; import { Schedule } from "./schedule"; -import { KEYS } from "./keys"; import type * as wsToServer from "@/actor/protocol/message/to-server"; import { CachedSerializer } from "./protocol/serde"; import { ActorInspector } from "@/inspector/actor"; import { ActorContext } from "./context"; import invariant from "invariant"; +import type { PersistedActor, PersistedConn, PersistedScheduleEvents } from "./persisted"; /** * Options for the `_saveState` method. @@ -72,14 +75,6 @@ export type ExtractActorConnState = ? ConnState : never; -/** State object that gets automatically persisted to storage. */ -interface PersistedActor { - // State - s: S; - // Connections - c: PersistedConn[]; -} - export class ActorInstance { // Shared actor context for this instance actorContext: ActorContext; @@ -155,7 +150,7 @@ export class ActorInstance { this.#name = name; this.#tags = tags; this.#region = region; - this.#schedule = new Schedule(this, actorDriver); + this.#schedule = new Schedule(this); this.inspector = new ActorInspector(this); // Initialize server @@ -171,7 +166,12 @@ export class ActorInstance { let vars: V | undefined = undefined; if ("createVars" in this.#config) { const dataOrPromise = this.#config.createVars( - this.actorContext as unknown as ActorContext, + this.actorContext as unknown as ActorContext< + undefined, + undefined, + undefined, + undefined + >, this.#actorDriver.getContext(this.#actorId), ); if (dataOrPromise instanceof Promise) { @@ -200,8 +200,101 @@ export class ActorInstance { this.#ready = true; } + async scheduleEvent( + timestamp: number, + fn: string, + args: unknown[], + ): Promise { + // Build event + const eventId = crypto.randomUUID(); + const newEvent: PersistedScheduleEvents = { + e: eventId, + t: timestamp, + a: fn, + ar: args, + }; + + this.actorContext.log.info("scheduling event", { + event: eventId, + timestamp, + action: fn + }); + + // Insert event in to index + const insertIndex = this.#persist.e.findIndex((x) => x.t > newEvent.t); + if (insertIndex === -1) { + this.#persist.e.push(newEvent); + } else { + this.#persist.e.splice(insertIndex, 0, newEvent); + } + + // Update alarm if: + // - this is the newest event (i.e. at beginning of array) or + // - this is the only event (i.e. the only event in the array) + if (insertIndex === 0 || this.#persist.e.length === 1) { + this.actorContext.log.info("setting alarm", { timestamp }); + await this.#actorDriver.setAlarm(this, newEvent.t); + } + } + async onAlarm() { - await this.#schedule.__onAlarm(); + const now = Date.now(); + this.actorContext.log.debug("alarm triggered", { now, events: this.#persist.e.length }); + + // Remove events from schedule that we're about to run + const runIndex = this.#persist.e.findIndex((x) => x.t <= now); + if (runIndex === -1) { + this.actorContext.log.debug("no events to run", { now }); + return; + } + const scheduleEvents = this.#persist.e.splice(0, runIndex + 1); + this.actorContext.log.debug("running events", { count: scheduleEvents.length }); + + // Set alarm for next event + if (this.#persist.e.length > 0) { + await this.#actorDriver.setAlarm(this, this.#persist.e[0].t); + } + + // Iterate by event key in order to ensure we call the events in order + for (const event of scheduleEvents) { + try { + this.actorContext.log.info("running action for event", { + event: event.e, + timestamp: event.t, + action: event.a, + args: event.ar + }); + + // Look up function + const fn: unknown = this.#config.actions[event.a]; + if (!fn) throw new Error(`Missing action for alarm ${event.a}`); + if (typeof fn !== "function") + throw new Error( + `Alarm function lookup for ${event.a} returned ${typeof fn}`, + ); + + // Call function + try { + await fn.call(undefined, this.actorContext, ...event.ar); + } catch (error) { + this.actorContext.log.error("error while running event", { + error: stringifyError(error), + event: event.e, + timestamp: event.t, + action: event.a, + args: event.ar, + }); + } + } catch (error) { + this.actorContext.log.error("internal error while running event", { + error: stringifyError(error), + event: event.e, + timestamp: event.t, + action: event.a, + args: event.ar, + }); + } + } } get stateEnabled() { @@ -268,9 +361,8 @@ export class ActorInstance { this.#persistChanged = false; // Write to KV - await this.#actorDriver.kvPut( + await this.#actorDriver.writePersistedData( this.#actorId, - KEYS.STATE.DATA, this.#persistRaw, ); @@ -359,12 +451,11 @@ export class ActorInstance { async #initialize() { // Read initial state - const [initialized, persistData] = (await this.#actorDriver.kvGetBatch( + const persistData = (await this.#actorDriver.readPersistedData( this.#actorId, - [KEYS.STATE.INITIALIZED, KEYS.STATE.DATA], - )) as [boolean, PersistedActor]; + )) as PersistedActor; - if (initialized) { + if (persistData !== undefined) { logger().info("actor restoring", { connections: persistData.c.length, }); @@ -406,7 +497,12 @@ export class ActorInstance { // Convert state to undefined since state is not defined yet here stateData = await this.#config.createState( - this.actorContext as unknown as ActorContext, + this.actorContext as unknown as ActorContext< + undefined, + undefined, + undefined, + undefined + >, ); } else if ("state" in this.#config) { stateData = structuredClone(this.#config.state); @@ -420,14 +516,12 @@ export class ActorInstance { const persist: PersistedActor = { s: stateData as S, c: [], + e: [], }; // Update state logger().debug("writing state"); - await this.#actorDriver.kvPutBatch(this.#actorId, [ - [KEYS.STATE.INITIALIZED, true], - [KEYS.STATE.DATA, persist], - ]); + await this.#actorDriver.writePersistedData(this.#actorId, persist); this.#setPersist(persist); } @@ -509,7 +603,12 @@ export class ActorInstance { if (this.#connStateEnabled) { if ("createConnState" in this.#config) { const dataOrPromise = this.#config.createConnState( - this.actorContext as unknown as ActorContext, + this.actorContext as unknown as ActorContext< + undefined, + undefined, + undefined, + undefined + >, onBeforeConnectOpts, ); if (dataOrPromise instanceof Promise) { @@ -723,6 +822,8 @@ export class ActorInstance { rpcName: string, args: unknown[], ): Promise { + invariant(this.#ready, "exucuting rpc before ready"); + // Prevent calling private or reserved methods if (!(rpcName in this.#config.actions)) { logger().warn("rpc does not exist", { rpcName }); diff --git a/packages/actor-core/src/actor/keys.ts b/packages/actor-core/src/actor/keys.ts deleted file mode 100644 index 599c65d45..000000000 --- a/packages/actor-core/src/actor/keys.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const KEYS = { - STATE: { - INITIALIZED: ["actor", "state", "initialized"], - DATA: ["actor", "state", "data"], - }, - SCHEDULE: { - SCHEDULE: ["actor", "schedule", "schedule"], - EVENT_PREFIX: ["actor", "schedule", "event"], - event(id: string): string[] { - return [...this.EVENT_PREFIX, id]; - }, - alarmError(fn: string): string[] { - return ["actor", "schedule", "alarm_error", fn]; - }, - }, -}; diff --git a/packages/actor-core/src/actor/persisted.ts b/packages/actor-core/src/actor/persisted.ts new file mode 100644 index 000000000..a02dc713d --- /dev/null +++ b/packages/actor-core/src/actor/persisted.ts @@ -0,0 +1,43 @@ +/** State object that gets automatically persisted to storage. */ +export interface PersistedActor { + // State + s: S; + // Connections + c: PersistedConn[]; + // Scheduled events + e: PersistedScheduleEvents[]; +} + +/** Object representing connection that gets persisted to storage. */ +export interface PersistedConn { + // ID + i: string; + // Token + t: string; + // Connection driver + d: string; + // Connection driver state + ds: unknown; + // Parameters + p: CP; + // State + s: CS; + // Subscriptions + su: PersistedSubscription[]; +} + +export interface PersistedSubscription { + // Event name + n: string; +} + +export interface PersistedScheduleEvents { + // Event ID + e: string; + // Timestamp + t: number; + // Action name + a: string; + // Arguments + ar: unknown[]; +} diff --git a/packages/actor-core/src/actor/schedule.ts b/packages/actor-core/src/actor/schedule.ts index 4b2f6f639..8948e8ccf 100644 --- a/packages/actor-core/src/actor/schedule.ts +++ b/packages/actor-core/src/actor/schedule.ts @@ -1,170 +1,17 @@ import type { AnyActorInstance } from "./instance"; -import type { ActorDriver } from "./driver"; -import { KEYS } from "./keys"; -import { logger } from "./log"; -import { stringifyError } from "@/common/utils"; - -interface ScheduleState { - // Sorted by timestamp asc - events: ScheduleIndexEvent[]; -} - -interface ScheduleIndexEvent { - timestamp: number; - eventId: string; -} - -interface ScheduleEvent { - timestamp: number; - fn: string; - args: unknown[]; -} export class Schedule { #actor: AnyActorInstance; - #driver: ActorDriver; - constructor(actor: AnyActorInstance, driver: ActorDriver) { + constructor(actor: AnyActorInstance) { this.#actor = actor; - this.#driver = driver; } async after(duration: number, fn: string, ...args: unknown[]) { - await this.#scheduleEvent(Date.now() + duration, fn, args); + await this.#actor.scheduleEvent(Date.now() + duration, fn, args); } async at(timestamp: number, fn: string, ...args: unknown[]) { - await this.#scheduleEvent(timestamp, fn, args); - } - - async #scheduleEvent( - timestamp: number, - fn: string, - args: unknown[], - ): Promise { - // Save event - const eventId = crypto.randomUUID(); - await this.#driver.kvPut( - this.#actor.id, - // biome-ignore lint/suspicious/noExplicitAny: - KEYS.SCHEDULE.event(eventId) as any, - { - timestamp, - fn, - args, - }, - ); - - // TODO: Clean this up to use list instead of get - // Read index - const schedule: ScheduleState = ((await this.#driver.kvGet( - this.#actor.id, - // biome-ignore lint/suspicious/noExplicitAny: - KEYS.SCHEDULE.SCHEDULE as any, - )) as ScheduleState) ?? { - events: [], - }; - - // Insert event in to index - const newEvent: ScheduleIndexEvent = { timestamp, eventId }; - const insertIndex = schedule.events.findIndex( - (x) => x.timestamp > newEvent.timestamp, - ); - if (insertIndex === -1) { - schedule.events.push(newEvent); - } else { - schedule.events.splice(insertIndex, 0, newEvent); - } - - // Write new index - await this.#driver.kvPut(this.#actor.id, KEYS.SCHEDULE.SCHEDULE, schedule); - - // Update alarm if: - // - this is the newest event (i.e. at beginning of array) or - // - this is the only event (i.e. the only event in the array) - if (insertIndex === 0 || schedule.events.length === 1) { - await this.#driver.setAlarm(this.#actor, newEvent.timestamp); - } - } - - async __onAlarm() { - const now = Date.now(); - - // Read index - const scheduleIndex: ScheduleState = ((await this.#driver.kvGet( - this.#actor.id, - KEYS.SCHEDULE.SCHEDULE, - )) as ScheduleState | undefined) ?? { events: [] }; - - // Remove events from schedule - const runIndex = scheduleIndex.events.findIndex((x) => x.timestamp < now); - const scheduleIndexEvents = scheduleIndex.events.splice(0, runIndex + 1); - - // Find events to trigger - const eventKeys = scheduleIndexEvents.map((x) => - KEYS.SCHEDULE.event(x.eventId), - ); - const scheduleEvents = (await this.#driver.kvGetBatch( - this.#actor.id, - eventKeys, - )) as ScheduleEvent[]; - await this.#driver.kvDeleteBatch(this.#actor.id, eventKeys); - - // Write new schedule - await this.#driver.kvPut( - this.#actor.id, - KEYS.SCHEDULE.SCHEDULE, - scheduleIndex, - ); - - // Set alarm for next event - if (scheduleIndex.events.length > 0) { - await this.#driver.setAlarm( - this.#actor, - scheduleIndex.events[0].timestamp, - ); - } - - // Iterate by event key in order to ensure we call the events in order - for (const event of scheduleEvents) { - try { - // Look up function - const fn: unknown = this.#actor[event.fn as keyof AnyActorInstance]; - if (!fn) throw new Error(`Missing function for alarm ${event.fn}`); - if (typeof fn !== "function") - throw new Error( - `Alarm function lookup for ${event.fn} returned ${typeof fn}`, - ); - - // Call function - try { - await fn.apply(this.#actor, event.args); - } catch (error) { - await this.#driver.kvPut( - this.#actor.id, - KEYS.SCHEDULE.alarmError(event.fn), - { - error: error, - timestamp: now, - }, - ); - } - } catch (err) { - logger().error("failed to run scheduled event", { - fn: event.fn, - error: stringifyError(err), - }); - - // Write internal error - await this.#driver.kvPut( - this.#actor.id, - KEYS.SCHEDULE.alarmError(event.fn), - { - error: stringifyError(err), - timestamp: now, - }, - ); - } - } + await this.#actor.scheduleEvent(timestamp, fn, args); } } diff --git a/packages/actor-core/src/client/handle.ts b/packages/actor-core/src/client/handle.ts index 87cdde594..122e6b150 100644 --- a/packages/actor-core/src/client/handle.ts +++ b/packages/actor-core/src/client/handle.ts @@ -10,7 +10,7 @@ import { logger } from "./log"; import { type WebSocketMessage as ConnMessage, messageLength } from "./utils"; import { ACTOR_HANDLES_SYMBOL, ClientRaw, DynamicImports } from "./client"; import { ActorDefinition, AnyActorDefinition } from "@/actor/definition"; -import pRetry from "p-retry"; +import pRetry, { AbortError } from "p-retry"; interface RpcInFlight { name: string; @@ -178,21 +178,32 @@ enc this.#connecting = true; // Attempt to reconnect indefinitely - await pRetry(this.#connectAndWait.bind(this), { - forever: true, - minTimeout: 250, - maxTimeout: 30_000, - - onFailedAttempt: (error) => { - logger().warn("failed to reconnect", { - attempt: error.attemptNumber, - error: stringifyError(error), - }); - }, + try { + await pRetry(this.#connectAndWait.bind(this), { + forever: true, + minTimeout: 250, + maxTimeout: 30_000, + + onFailedAttempt: (error) => { + logger().warn("failed to reconnect", { + attempt: error.attemptNumber, + error: stringifyError(error), + }); + }, - // Cancel retry if aborted - signal: this.#abortController.signal, - }); + // Cancel retry if aborted + signal: this.#abortController.signal, + }); + } catch (err) { + if ((err as Error).name === "AbortError") { + // Ignore abortions + logger().info("connection retry aborted"); + return; + } else { + // Unknown error + throw err; + } + } this.#connecting = false; } @@ -382,7 +393,6 @@ enc // // These properties will be undefined const closeEvent = event as CloseEvent; - console.log('close event', JSON.stringify(event)); if (closeEvent.wasClean) { logger().info("socket closed", { code: closeEvent.code, diff --git a/packages/actor-core/src/common/utils.ts b/packages/actor-core/src/common/utils.ts index 3678ba3e1..b2bc13be6 100644 --- a/packages/actor-core/src/common/utils.ts +++ b/packages/actor-core/src/common/utils.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { ContentfulStatusCode } from "hono/utils/http-status"; import * as errors from "@/actor/errors"; -import { Logger } from "./log"; +import type { Logger } from "./log"; export const ActorTagsSchema = z.record(z.string()); diff --git a/packages/actor-core/src/driver-helpers/config.ts b/packages/actor-core/src/driver-helpers/config.ts index 2ffde6757..b58fa1f3b 100644 --- a/packages/actor-core/src/driver-helpers/config.ts +++ b/packages/actor-core/src/driver-helpers/config.ts @@ -13,7 +13,6 @@ import type { import type { CoordinateDriver } from "@/topologies/coordinate/driver"; import type { ManagerDriver } from "@/manager/driver"; import type { ActorDriver } from "@/actor/driver"; -import type { InspectorConnHandler } from "@/inspector/common"; export const TopologySchema = z.enum(["standalone", "partition", "coordinate"]); export type Topology = z.infer; diff --git a/packages/actor-core/src/driver-helpers/mod.ts b/packages/actor-core/src/driver-helpers/mod.ts index 14367f368..1f16b6888 100644 --- a/packages/actor-core/src/driver-helpers/mod.ts +++ b/packages/actor-core/src/driver-helpers/mod.ts @@ -8,11 +8,7 @@ export { CoordinateDriver, StartActorAndAcquireLeaseOutput, } from "@/topologies/coordinate/driver"; -export { - ActorDriver, - KvKey, - KvValue, -} from "@/actor/driver"; +export { ActorDriver } from "@/actor/driver"; export { ManagerDriver, CreateActorInput, diff --git a/packages/actor-core/src/test/driver/actor.ts b/packages/actor-core/src/test/driver/actor.ts index 5c464bf72..1ad2238d2 100644 --- a/packages/actor-core/src/test/driver/actor.ts +++ b/packages/actor-core/src/test/driver/actor.ts @@ -1,9 +1,4 @@ -import type { - ActorDriver, - KvKey, - KvValue, - AnyActorInstance, -} from "@/driver-helpers/mod"; +import type { ActorDriver, AnyActorInstance } from "@/driver-helpers/mod"; import type { TestGlobalState } from "./global_state"; export type ActorDriverContext = Record; @@ -19,51 +14,12 @@ export class TestActorDriver implements ActorDriver { return {}; } - async kvGet(actorId: string, key: KvKey): Promise { - const serializedKey = this.#serializeKey(key); - const value = this.#state.getKv(actorId, serializedKey); - - if (value !== undefined) return JSON.parse(value); - return undefined; - } - - async kvGetBatch( - actorId: string, - keys: KvKey[], - ): Promise<(KvValue | undefined)[]> { - return keys.map((key) => { - const serializedKey = this.#serializeKey(key); - const value = this.#state.getKv(actorId, serializedKey); - if (value !== undefined) return JSON.parse(value); - return undefined; - }); + async readPersistedData(actorId: string): Promise { + return this.#state.readPersistedData(actorId); } - async kvPut(actorId: string, key: KvKey, value: KvValue): Promise { - const serializedKey = this.#serializeKey(key); - this.#state.putKv(actorId, serializedKey, JSON.stringify(value)); - } - - async kvPutBatch( - actorId: string, - keyValuePairs: [KvKey, KvValue][], - ): Promise { - for (const [key, value] of keyValuePairs) { - const serializedKey = this.#serializeKey(key); - this.#state.putKv(actorId, serializedKey, JSON.stringify(value)); - } - } - - async kvDelete(actorId: string, key: KvKey): Promise { - const serializedKey = this.#serializeKey(key); - this.#state.deleteKv(actorId, serializedKey); - } - - async kvDeleteBatch(actorId: string, keys: KvKey[]): Promise { - for (const key of keys) { - const serializedKey = this.#serializeKey(key); - this.#state.deleteKv(actorId, serializedKey); - } + async writePersistedData(actorId: string, data: unknown): Promise { + this.#state.writePersistedData(actorId, data); } async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { @@ -71,9 +27,4 @@ export class TestActorDriver implements ActorDriver { actor.onAlarm(); }, timestamp - Date.now()); } - - // Simple key serialization without depending on keys.ts - #serializeKey(key: KvKey): string { - return JSON.stringify(key); - } } diff --git a/packages/actor-core/src/test/driver/global_state.ts b/packages/actor-core/src/test/driver/global_state.ts index d9120a291..e3ea6a98a 100644 --- a/packages/actor-core/src/test/driver/global_state.ts +++ b/packages/actor-core/src/test/driver/global_state.ts @@ -10,8 +10,7 @@ export class ActorState { name: string; tags: ActorTags; - // KV store - maps serialized keys to serialized values - kvStore: Map = new Map(); + persistedData: unknown = undefined; constructor(id: string, name: string, tags: ActorTags) { this.id = id; @@ -39,29 +38,12 @@ export class TestGlobalState { return actor; } - /** - * Get a value from KV store - */ - getKv(actorId: string, serializedKey: string): string | undefined { - return this.#getActor(actorId).kvStore.get(serializedKey); + readPersistedData(actorId: string): unknown | undefined { + return this.#getActor(actorId).persistedData; } - /** - * Put a value into KV store - */ - putKv(actorId: string, serializedKey: string, value: string): void { - let actor = this.#actors.get(actorId); - if (!actor) { - throw new Error(`Actor does not exist for ID: ${actorId}`); - } - actor.kvStore.set(serializedKey, value); - } - - /** - * Delete a value from KV store - */ - deleteKv(actorId: string, serializedKey: string): void { - this.#getActor(actorId).kvStore.delete(serializedKey); + writePersistedData(actorId: string, data: unknown) { + this.#getActor(actorId).persistedData = data; } /** diff --git a/packages/drivers/file-system/package.json b/packages/drivers/file-system/package.json index 5d41a5dd4..5cf98fb1c 100644 --- a/packages/drivers/file-system/package.json +++ b/packages/drivers/file-system/package.json @@ -20,17 +20,20 @@ "sideEffects": false, "scripts": { "build": "tsup src/mod.ts", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run" }, "peerDependencies": { "actor-core": "*" }, "devDependencies": { + "@actor-core/driver-test-suite": "workspace:*", "@types/invariant": "^2", "@types/node": "^22.14.0", "actor-core": "workspace:*", "tsup": "^8.4.0", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^3.1.1" }, "dependencies": { "env-paths": "^3.0.0", diff --git a/packages/drivers/file-system/src/actor.ts b/packages/drivers/file-system/src/actor.ts index 3f2b94ddb..27d7871b5 100644 --- a/packages/drivers/file-system/src/actor.ts +++ b/packages/drivers/file-system/src/actor.ts @@ -1,4 +1,4 @@ -import type { ActorDriver, KvKey, KvValue, AnyActorInstance } from "actor-core/driver-helpers"; +import type { ActorDriver, AnyActorInstance } from "actor-core/driver-helpers"; import type { FileSystemGlobalState } from "./global_state"; export type ActorDriverContext = Record; @@ -24,83 +24,21 @@ export class FileSystemActorDriver implements ActorDriver { return {}; } - async kvGet(actorId: string, key: KvKey): Promise { - const serializedKey = this.#serializeKey(key); - const value = this.#state.getKv(actorId, serializedKey); - - if (value !== undefined) return JSON.parse(value); - return undefined; - } - - async kvGetBatch( - actorId: string, - keys: KvKey[], - ): Promise<(KvValue | undefined)[]> { - return keys.map(key => { - const serializedKey = this.#serializeKey(key); - const value = this.#state.getKv(actorId, serializedKey); - return value !== undefined ? JSON.parse(value) : undefined; - }); + async readPersistedData(actorId: string): Promise { + return this.#state.readPersistedData(actorId); } - async kvPut(actorId: string, key: KvKey, value: KvValue): Promise { - const serializedKey = this.#serializeKey(key); - this.#state.putKv(actorId, serializedKey, JSON.stringify(value)); + async writePersistedData(actorId: string, data: unknown): Promise { + this.#state.writePersistedData(actorId, data); // Save state to disk await this.#state.saveActorState(actorId); } - async kvPutBatch( - actorId: string, - keyValuePairs: [KvKey, KvValue][], - ): Promise { - for (const [key, value] of keyValuePairs) { - const serializedKey = this.#serializeKey(key); - this.#state.putKv(actorId, serializedKey, JSON.stringify(value)); - } - - // Save state to disk after all changes - await this.#state.saveActorState(actorId); - } - - async kvDelete(actorId: string, key: KvKey): Promise { - const serializedKey = this.#serializeKey(key); - const state = this.#state.loadActorState(actorId); - - // Delete value and save if it exists - if (state.kvStore.has(serializedKey)) { - this.#state.deleteKv(actorId, serializedKey); - await this.#state.saveActorState(actorId); - } - } - - async kvDeleteBatch(actorId: string, keys: KvKey[]): Promise { - const state = this.#state.loadActorState(actorId); - - let hasChanges = false; - for (const key of keys) { - const serializedKey = this.#serializeKey(key); - if (state.kvStore.has(serializedKey)) { - this.#state.deleteKv(actorId, serializedKey); - hasChanges = true; - } - } - - if (hasChanges) { - await this.#state.saveActorState(actorId); - } - } - async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { const delay = Math.max(0, timestamp - Date.now()); setTimeout(() => { actor.onAlarm(); }, delay); } - - // Simple key serialization without depending on keys.ts - #serializeKey(key: KvKey): string { - return JSON.stringify(key); - } } \ No newline at end of file diff --git a/packages/drivers/file-system/src/global_state.ts b/packages/drivers/file-system/src/global_state.ts index 85efb6b31..fc2a86c96 100644 --- a/packages/drivers/file-system/src/global_state.ts +++ b/packages/drivers/file-system/src/global_state.ts @@ -12,13 +12,23 @@ import { import invariant from "invariant"; /** - * Actor state object for caching + * Class representing an actor's state */ -export interface ActorState { +export class ActorState { + // Basic actor information + initialized = true; id: string; name: string; - tags: Record; - kvStore: Map; + tags: ActorTags; + + // Persisted data + persistedData: unknown = undefined; + + constructor(id: string, name: string, tags: ActorTags) { + this.id = id; + this.name = name; + this.tags = tags; + } } /** @@ -64,21 +74,13 @@ export class FileSystemGlobalState { const stateData = fsSync.readFileSync(stateFilePath, "utf8"); const rawState = JSON.parse(stateData); - // Convert kvData object to kvStore Map - const kvStore = new Map(); - if (rawState.kvData && typeof rawState.kvData === "object") { - for (const [key, value] of Object.entries(rawState.kvData)) { - kvStore.set(key, value as string); - } - } - - // Create actor state with Map-based kvStore - const state: ActorState = { - id: rawState.id, - name: rawState.name, - tags: rawState.tags, - kvStore, - }; + // Create actor state with persistedData + const state = new ActorState( + rawState.id, + rawState.name, + rawState.tags + ); + state.persistedData = rawState.persistedData; this.#stateCache.set(actorId, state); } catch (error) { @@ -120,27 +122,19 @@ export class FileSystemGlobalState { } /** - * Get a value from KV store - */ - getKv(actorId: string, serializedKey: string): string | undefined { - const state = this.loadActorState(actorId); - return state.kvStore.get(serializedKey); - } - - /** - * Put a value into KV store + * Read persisted data for an actor */ - putKv(actorId: string, serializedKey: string, value: string): void { + readPersistedData(actorId: string): unknown | undefined { const state = this.loadActorState(actorId); - state.kvStore.set(serializedKey, value); + return state.persistedData; } /** - * Delete a value from KV store + * Write persisted data for an actor */ - deleteKv(actorId: string, serializedKey: string): void { + writePersistedData(actorId: string, data: unknown): void { const state = this.loadActorState(actorId); - state.kvStore.delete(serializedKey); + state.persistedData = data; } /** @@ -155,18 +149,12 @@ export class FileSystemGlobalState { const stateFilePath = this.getStateFilePath(actorId); try { - // Convert Map to plain object for serialization - const kvData: Record = {}; - for (const [key, value] of state.kvStore.entries()) { - kvData[key] = value; - } - // Create serializable object const serializedState = { id: state.id, name: state.name, tags: state.tags, - kvData + persistedData: state.persistedData }; await fs.writeFile(stateFilePath, JSON.stringify(serializedState), "utf8"); @@ -210,12 +198,7 @@ export class FileSystemGlobalState { await ensureDirectoryExists(actorDir); // Create initial state - const newState: ActorState = { - id: actorId, - name, - tags, - kvStore: new Map(), - }; + const newState = new ActorState(actorId, name, tags); // Cache the state this.#stateCache.set(actorId, newState); @@ -225,21 +208,14 @@ export class FileSystemGlobalState { } /** - * Find an actor by name and tags from the cache + * Find an actor by filter function */ - findActor(name: string, tags: ActorTags): string | undefined { - const serializedSearchTags = JSON.stringify(tags); - - // Search through cached actors - for (const [actorId, state] of this.#stateCache.entries()) { - if ( - state.name === name && - JSON.stringify(state.tags) === serializedSearchTags - ) { - return actorId; + findActor(filter: (actor: ActorState) => boolean): ActorState | undefined { + for (const actor of this.#stateCache.values()) { + if (filter(actor)) { + return actor; } } - return undefined; } diff --git a/packages/drivers/file-system/src/manager.ts b/packages/drivers/file-system/src/manager.ts index 62b2bd8ae..b105051f5 100644 --- a/packages/drivers/file-system/src/manager.ts +++ b/packages/drivers/file-system/src/manager.ts @@ -59,19 +59,31 @@ export class FileSystemManagerDriver implements ManagerDriver { name, tags, }: GetWithTagsInput): Promise { - try { - // Use the existing findActor method from global state - const actorId = this.#state.findActor(name, tags); + // 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; - if (actorId) { - return { - endpoint: buildActorEndpoint(baseUrl, actorId), - name, - tags, - }; + 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) { + return false; + } } - } catch (error) { - logger().error("failed to search for actors", { name, tags, error }); + return true; + }); + + if (actor) { + return { + endpoint: buildActorEndpoint(baseUrl, actor.id), + name, + tags: actor.tags, + }; } return undefined; diff --git a/packages/drivers/file-system/tests/driver-tests.test.ts b/packages/drivers/file-system/tests/driver-tests.test.ts new file mode 100644 index 000000000..9ebe9be1b --- /dev/null +++ b/packages/drivers/file-system/tests/driver-tests.test.ts @@ -0,0 +1,35 @@ +import { + runDriverTests, + createTestRuntime, +} from "@actor-core/driver-test-suite"; +import { + FileSystemActorDriver, + FileSystemManagerDriver, + FileSystemGlobalState, +} from "../src/mod"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; + +runDriverTests({ + async start(appPath: string) { + return await createTestRuntime(appPath, async (app) => { + // Create a unique temp directory for each test + const testDir = path.join( + os.tmpdir(), + `actor-core-fs-tests-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + ); + await fs.mkdir(testDir, { recursive: true }); + + const fileSystemState = new FileSystemGlobalState(testDir); + return { + actorDriver: new FileSystemActorDriver(fileSystemState), + managerDriver: new FileSystemManagerDriver(app, fileSystemState), + async cleanup() { + await fs.rmdir(testDir, { recursive: true }); + + } + }; + }); + }, +}); diff --git a/packages/drivers/file-system/tsconfig.json b/packages/drivers/file-system/tsconfig.json index accb9677a..d4b580089 100644 --- a/packages/drivers/file-system/tsconfig.json +++ b/packages/drivers/file-system/tsconfig.json @@ -5,5 +5,5 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*", "test/**/*"] } diff --git a/packages/drivers/memory/package.json b/packages/drivers/memory/package.json index 32cbe0e89..b7d3c63b8 100644 --- a/packages/drivers/memory/package.json +++ b/packages/drivers/memory/package.json @@ -20,18 +20,21 @@ "sideEffects": false, "scripts": { "build": "tsup src/mod.ts", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run" }, "peerDependencies": { "actor-core": "*" }, "devDependencies": { + "@actor-core/driver-test-suite": "workspace:*", "actor-core": "workspace:*", "tsup": "^8.4.0", "typescript": "^5.5.2" }, "dependencies": { "@types/node": "^22.13.1", - "hono": "^4.7.0" + "hono": "^4.7.0", + "vitest": "^3.1.1" } } diff --git a/packages/drivers/memory/src/actor.ts b/packages/drivers/memory/src/actor.ts index 9143400a5..2f8f2659f 100644 --- a/packages/drivers/memory/src/actor.ts +++ b/packages/drivers/memory/src/actor.ts @@ -1,4 +1,4 @@ -import type { ActorDriver, KvKey, KvValue, AnyActorInstance } from "actor-core/driver-helpers"; +import type { ActorDriver, AnyActorInstance } from "actor-core/driver-helpers"; import type { MemoryGlobalState } from "./global_state"; export type ActorDriverContext = Record; @@ -14,61 +14,18 @@ export class MemoryActorDriver implements ActorDriver { return {}; } - async kvGet(actorId: string, key: KvKey): Promise { - const serializedKey = this.#serializeKey(key); - const value = this.#state.getKv(actorId, serializedKey); - - if (value !== undefined) return JSON.parse(value); - return undefined; - } - - async kvGetBatch( - actorId: string, - keys: KvKey[], - ): Promise<(KvValue | undefined)[]> { - return keys.map((key) => { - const serializedKey = this.#serializeKey(key); - const value = this.#state.getKv(actorId, serializedKey); - if (value !== undefined) return JSON.parse(value); - return undefined; - }); + async readPersistedData(actorId: string): Promise { + return this.#state.readPersistedData(actorId); } - async kvPut(actorId: string, key: KvKey, value: KvValue): Promise { - const serializedKey = this.#serializeKey(key); - this.#state.putKv(actorId, serializedKey, JSON.stringify(value)); - } - - async kvPutBatch( - actorId: string, - keyValuePairs: [KvKey, KvValue][], - ): Promise { - for (const [key, value] of keyValuePairs) { - const serializedKey = this.#serializeKey(key); - this.#state.putKv(actorId, serializedKey, JSON.stringify(value)); - } - } - - async kvDelete(actorId: string, key: KvKey): Promise { - const serializedKey = this.#serializeKey(key); - this.#state.deleteKv(actorId, serializedKey); - } - - async kvDeleteBatch(actorId: string, keys: KvKey[]): Promise { - for (const key of keys) { - const serializedKey = this.#serializeKey(key); - this.#state.deleteKv(actorId, serializedKey); - } + async writePersistedData(actorId: string, data: unknown): Promise { + this.#state.writePersistedData(actorId, data); } async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { + const delay = Math.max(timestamp - Date.now(), 0); setTimeout(() => { actor.onAlarm(); - }, timestamp - Date.now()); - } - - // Simple key serialization without depending on keys.ts - #serializeKey(key: KvKey): string { - return JSON.stringify(key); + }, delay); } } diff --git a/packages/drivers/memory/src/global_state.ts b/packages/drivers/memory/src/global_state.ts index 9cb91ea6a..04b77f447 100644 --- a/packages/drivers/memory/src/global_state.ts +++ b/packages/drivers/memory/src/global_state.ts @@ -10,8 +10,8 @@ export class ActorState { name: string; tags: ActorTags; - // KV store - maps serialized keys to serialized values - kvStore: Map = new Map(); + // Persisted data + persistedData: unknown = undefined; constructor(id: string, name: string, tags: ActorTags) { this.id = id; @@ -24,13 +24,8 @@ export class ActorState { * Global state singleton for the memory driver */ export class MemoryGlobalState { - // Single map for all actor state #actors: Map = new Map(); - /** - * Get an actor by ID, throwing an error if it doesn't exist - * @private - */ #getActor(actorId: string): ActorState { const actor = this.#actors.get(actorId); if (!actor) { @@ -39,34 +34,14 @@ export class MemoryGlobalState { return actor; } - /** - * Get a value from KV store - */ - getKv(actorId: string, serializedKey: string): string | undefined { - return this.#getActor(actorId).kvStore.get(serializedKey); + readPersistedData(actorId: string): unknown | undefined { + return this.#getActor(actorId).persistedData; } - /** - * Put a value into KV store - */ - putKv(actorId: string, serializedKey: string, value: string): void { - const actor = this.#actors.get(actorId); - if (!actor) { - throw new Error(`Actor does not exist for ID: ${actorId}`); - } - actor.kvStore.set(serializedKey, value); + writePersistedData(actorId: string, data: unknown) { + this.#getActor(actorId).persistedData = data; } - /** - * Delete a value from KV store - */ - deleteKv(actorId: string, serializedKey: string): void { - this.#getActor(actorId).kvStore.delete(serializedKey); - } - - /** - * Create or update an actor - */ createActor(actorId: string, name: string, tags: ActorTags): void { // Create actor state if it doesn't exist if (!this.#actors.has(actorId)) { @@ -76,11 +51,6 @@ export class MemoryGlobalState { } } - /** - * Find an actor by a filter function - * @param filter A function that takes an ActorState and returns true if it matches the filter criteria - * @returns The matching ActorState or undefined if no match is found - */ findActor(filter: (actor: ActorState) => boolean): ActorState | undefined { for (const actor of this.#actors.values()) { if (filter(actor)) { @@ -90,23 +60,10 @@ export class MemoryGlobalState { return undefined; } - /** - * Get actor state - */ getActor(actorId: string): ActorState | undefined { return this.#actors.get(actorId); } - /** - * Check if an actor exists - */ - hasActor(actorId: string): boolean { - return this.#actors.has(actorId); - } - - /** - * Get all actors - */ getAllActors(): ActorState[] { return Array.from(this.#actors.values()); } diff --git a/packages/drivers/memory/src/manager.ts b/packages/drivers/memory/src/manager.ts index 93ad125a1..ba9b122ad 100644 --- a/packages/drivers/memory/src/manager.ts +++ b/packages/drivers/memory/src/manager.ts @@ -50,13 +50,24 @@ export class MemoryManagerDriver implements ManagerDriver { name, tags, }: GetWithTagsInput): Promise { - // TODO: Update tag search to use inverse tree - const serializedSearchTags = JSON.stringify(tags); - const actor = this.#state.findActor( - (actor) => - actor.name === name && - JSON.stringify(actor.tags) === serializedSearchTags, - ); + // 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) { + return false; + } + } + return true; + }); if (actor) { return { diff --git a/packages/drivers/memory/tests/driver-tests.test.ts b/packages/drivers/memory/tests/driver-tests.test.ts new file mode 100644 index 000000000..894894916 --- /dev/null +++ b/packages/drivers/memory/tests/driver-tests.test.ts @@ -0,0 +1,18 @@ +import { runDriverTests, createTestRuntime } from "@actor-core/driver-test-suite"; +import { + MemoryActorDriver, + MemoryManagerDriver, + MemoryGlobalState, +} from "../src/mod"; + +runDriverTests({ + async start(appPath: string) { + return await createTestRuntime(appPath, async (app) => { + const memoryState = new MemoryGlobalState(); + return { + actorDriver: new MemoryActorDriver(memoryState), + managerDriver: new MemoryManagerDriver(app, memoryState), + }; + }); + }, +}); diff --git a/packages/drivers/memory/tsconfig.json b/packages/drivers/memory/tsconfig.json index accb9677a..a15ae12a6 100644 --- a/packages/drivers/memory/tsconfig.json +++ b/packages/drivers/memory/tsconfig.json @@ -5,5 +5,5 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*", "fixtures/apps/**/*"] } diff --git a/packages/drivers/redis/package.json b/packages/drivers/redis/package.json index 8c2af2b3f..c6c8e7d11 100644 --- a/packages/drivers/redis/package.json +++ b/packages/drivers/redis/package.json @@ -8,6 +8,16 @@ ], "type": "module", "exports": { + ".": { + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.js" + }, + "require": { + "types": "./dist/mod.d.cts", + "default": "./dist/mod.cjs" + } + }, "./actor": { "import": { "types": "./dist/actor.d.ts", @@ -41,16 +51,20 @@ }, "sideEffects": false, "scripts": { - "build": "tsup src/actor.ts src/manager.ts src/coordinate.ts", - "check-types": "tsc --noEmit" + "build": "tsup src/actor.ts src/manager.ts src/coordinate.ts src/mod.ts", + "check-types": "tsc --noEmit", + "test": "vitest run" }, "peerDependencies": { "actor-core": "workspace:*" }, "devDependencies": { + "@actor-core/driver-test-suite": "workspace:*", + "@types/node": "^22.13.1", "actor-core": "workspace:*", "tsup": "^8.4.0", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^1.4.0" }, "dependencies": { "@types/node": "^22.13.1", diff --git a/packages/drivers/redis/src/actor.ts b/packages/drivers/redis/src/actor.ts index fa27aea67..f99e0e06a 100644 --- a/packages/drivers/redis/src/actor.ts +++ b/packages/drivers/redis/src/actor.ts @@ -1,62 +1,39 @@ -import type { ActorDriver, KvKey, KvValue } from "actor-core/driver-helpers"; +import type { ActorDriver, AnyActorInstance } from "actor-core/driver-helpers"; import type Redis from "ioredis"; import { KEYS } from "./keys"; -import { AnyActorInstance } from "actor-core/driver-helpers"; export interface ActorDriverContext { - redis: Redis; + redis: Redis; } export class RedisActorDriver implements ActorDriver { - #redis: Redis; - - constructor(redis: Redis) { - this.#redis = redis; - } - - getContext(_actorId: string): ActorDriverContext { - return { redis: this.#redis }; - } - - async kvGet(actorId: string, key: KvKey): Promise { - const value = await this.#redis.get(this.#serializeKey(actorId, key)); - if (value !== null) return JSON.parse(value); - return undefined; - } - - async kvGetBatch(actorId: string, key: KvKey[]): Promise<(KvValue | undefined)[]> { - const values = await this.#redis.mget(key.map((k) => this.#serializeKey(actorId, k))); - return values.map((v) => { - if (v !== null) return JSON.parse(v); - return undefined; - }); - } - - async kvPut(actorId: string, key: KvKey, value: KvValue): Promise { - await this.#redis.set(this.#serializeKey(actorId, key), JSON.stringify(value)); - } - - async kvPutBatch(actorId: string, key: [KvKey, KvValue][]): Promise { - await this.#redis.mset( - Object.fromEntries( - key.map(([k, v]) => [this.#serializeKey(actorId, k), JSON.stringify(v)]), - ), - ); - } - - async kvDelete(actorId: string, key: KvKey): Promise { - await this.#redis.del(this.#serializeKey(actorId, key)); - } - - async kvDeleteBatch(actorId: string, key: KvKey[]): Promise { - await this.#redis.del(key.map((k) => this.#serializeKey(actorId, k))); - } - - async setAlarm(_actor: AnyActorInstance, _timestamp: number): Promise { - throw new Error("Alarms are not yet implemented for this driver."); - } - - #serializeKey(actorId: string, key: KvKey): string { - return KEYS.ACTOR.kv(actorId, typeof key === 'string' ? key : JSON.stringify(key)); - } + #redis: Redis; + + constructor(redis: Redis) { + this.#redis = redis; + } + + getContext(_actorId: string): ActorDriverContext { + return { redis: this.#redis }; + } + + async readPersistedData(actorId: string): Promise { + const data = await this.#redis.get(KEYS.ACTOR.persistedData(actorId)); + if (data !== null) return JSON.parse(data); + return undefined; + } + + async writePersistedData(actorId: string, data: unknown): Promise { + await this.#redis.set( + KEYS.ACTOR.persistedData(actorId), + JSON.stringify(data), + ); + } + + async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { + const delay = Math.max(timestamp - Date.now(), 0); + setTimeout(() => { + actor.onAlarm(); + }, delay); + } } diff --git a/packages/drivers/redis/src/coordinate.ts b/packages/drivers/redis/src/coordinate.ts index 05253084c..7403cb090 100644 --- a/packages/drivers/redis/src/coordinate.ts +++ b/packages/drivers/redis/src/coordinate.ts @@ -92,7 +92,7 @@ export class RedisCoordinateDriver implements CoordinateDriver { // Execute multi to get actor info and attempt to acquire lease in a single operation const execRes = await this.#redis .multi() - .mget([KEYS.ACTOR.initialized(actorId), KEYS.ACTOR.tags(actorId)]) + .mget([KEYS.ACTOR.initialized(actorId), KEYS.ACTOR.metadata(actorId)]) .actorPeerAcquireLease( KEYS.ACTOR.LEASE.node(actorId), selfNodeId, @@ -109,20 +109,21 @@ export class RedisCoordinateDriver implements CoordinateDriver { if (mgetErr) throw new Error(`Redis MGET error: ${mgetErr}`); if (leaseErr) throw new Error(`Redis acquire lease error: ${leaseErr}`); - const [initialized, tagsRaw] = mgetRes as [string | null, string | null]; + const [initialized, metadataRaw] = mgetRes as [string | null, string | null]; const leaderNodeId = leaseRes as unknown as string; if (!initialized) { return { actor: undefined }; } - // Parse tags if present - if (!tagsRaw) throw new Error("Actor should have tags if initialized."); - let tags = JSON.parse(tagsRaw); + // Parse metadata if present + if (!metadataRaw) throw new Error("Actor should have metadata if initialized."); + const metadata = JSON.parse(metadataRaw); return { actor: { - tags, + name: metadata.name, + tags: metadata.tags, leaderNodeId, }, }; diff --git a/packages/drivers/redis/src/keys.ts b/packages/drivers/redis/src/keys.ts index 146557f2b..78f0968a3 100644 --- a/packages/drivers/redis/src/keys.ts +++ b/packages/drivers/redis/src/keys.ts @@ -7,11 +7,9 @@ export const KEYS = { node: (actorId: string) => `actor:${actorId}:lease:node`, }, // KEY - name: (actorId: string) => `actor:${actorId}:name`, + metadata: (actorId: string) => `actor:${actorId}:metadata`, // KEY - tags: (actorId: string) => `actor:${actorId}:tags`, - // KEY - kv: (actorId: string, key: string) => `actor:${actorId}:kv:${key}`, + persistedData: (actorId: string) => `actor:${actorId}:persisted_data`, }, }; diff --git a/packages/drivers/redis/src/manager.ts b/packages/drivers/redis/src/manager.ts index 890eb80d9..f67a33fb2 100644 --- a/packages/drivers/redis/src/manager.ts +++ b/packages/drivers/redis/src/manager.ts @@ -1,5 +1,6 @@ import type { CreateActorInput, + CreateActorOutput, GetActorOutput, GetForIdInput, GetWithTagsInput, @@ -7,45 +8,64 @@ import type { } from "actor-core/driver-helpers"; import type Redis from "ioredis"; import { KEYS } from "./keys"; +import { ManagerInspector } from "actor-core/inspector"; +import type { ActorCoreApp } from "actor-core"; + +interface Actor { + id: string; + name: string; + tags: Record; + region?: string; + createdAt?: string; + destroyedAt?: string; +} +/** + * Redis Manager Driver for Actor-Core + * Implements efficient tag-based indexing using Redis Sets + */ export class RedisManagerDriver implements ManagerDriver { #redis: Redis; - - constructor(redis: Redis) { + #app?: ActorCoreApp; + + /** + * @internal + */ + inspector: ManagerInspector = new ManagerInspector(this, { + getAllActors: () => { + // Create a function that returns an array of actors directly + // Not returning a Promise since the ManagerInspector expects a synchronous function + const actors: Actor[] = []; + + // Return empty array since we can't do async operations here + // The actual data will be fetched when needed by calling getAllActors() manually + return actors; + }, + getAllTypesOfActors: () => { + if (!this.#app) return []; + return Object.keys(this.#app.config.actors); + }, + }); + + constructor(redis: Redis, app?: ActorCoreApp) { this.#redis = redis; + this.#app = app; } async getForId({ baseUrl, actorId, }: GetForIdInput): Promise { - // TODO: Error handling - - //// Validate actor - //if ((res.actor.tags as ActorTags).access !== "public") { - // // TODO: Throw 404 that matches the 404 from Fern if the actor is not found - // throw new Error(`Actor with ID ${query.getForId.actorId} is private`); - //} - //if (res.actor.destroyedAt) { - // throw new Error( - // `Actor with ID ${query.getForId.actorId} already destroyed`, - // ); - //} - // - //return res.actor; - - // Get tags from Redis - const [name, tagsStr] = await this.#redis.mget( - KEYS.ACTOR.name(actorId), - KEYS.ACTOR.tags(actorId), - ); + // Get metadata from Redis + const metadataStr = await this.#redis.get(KEYS.ACTOR.metadata(actorId)); // If the actor doesn't exist, return undefined - if (!name || !tagsStr) { + if (!metadataStr) { return undefined; } - const tags = JSON.parse(tagsStr); + const metadata = JSON.parse(metadataStr); + const { name, tags } = metadata; return { endpoint: buildActorEndpoint(baseUrl, actorId), @@ -59,76 +79,144 @@ export class RedisManagerDriver implements ManagerDriver { name, tags, }: GetWithTagsInput): Promise { - // TODO: use an inverse tree for correct tag looups + 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)); - const actorId = await this.#redis.get( - `actor_tags:${name}:${JSON.stringify(tags)}:id`, - ); - if (actorId) { - // Get the complete tags for the actor - const [name, tagsStr] = await this.#redis.mget( - KEYS.ACTOR.name(actorId), - KEYS.ACTOR.tags(actorId), - ); - if (!name || !tagsStr) throw new Error("No actor found for ID"); - const actorTags = JSON.parse(tagsStr); - - return { - endpoint: buildActorEndpoint(baseUrl, actorId), - name, - tags: actorTags, - }; + if (actorIds.length > 0) { + // Use the first actor (should be consistent for the same query) + const actorId = actorIds[0]; + return this.#buildActorOutput(baseUrl, 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)); } - return undefined; - //// TODO(RVT-4248): Don't return actors that aren't networkable yet - //actors = actors.filter((a) => { - // // 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"); - // } - // - // for (const portName in a.network.ports) { - // const port = a.network.ports[portName]; - // if (!port.hostname || !port.port) return false; - // } - // return true; - //}); - // - //if (actors.length === 0) { - // return undefined; - //} - // - //// Make the chosen actor consistent - //if (actors.length > 1) { - // actors.sort((a, b) => a.id.localeCompare(b.id)); - //} - // - //return actors[0]; + // 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; } async createActor({ baseUrl, name, tags, - }: CreateActorInput): Promise { + }: CreateActorInput): Promise { const actorId = crypto.randomUUID(); - await this.#redis.mset({ - [KEYS.ACTOR.initialized(actorId)]: "1", - [KEYS.ACTOR.name(actorId)]: name, - [KEYS.ACTOR.tags(actorId)]: JSON.stringify(tags), - [`actor_tags:${name}:${JSON.stringify(tags)}:id`]: actorId, - }); + // 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 })); + + // 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); + } + + // Execute all commands atomically + await pipeline.exec(); + + // Notify inspector of actor creation with minimal data + // to avoid async Redis calls after cleanup + this.inspector.onActorsChange([ + { + id: actorId, + name, + tags, + }, + ]); return { endpoint: buildActorEndpoint(baseUrl, actorId.toString()), + }; + } + + // Helper method to get all actors (for inspector) + private async getAllActors(): Promise { + const keys = await this.#redis.keys( + KEYS.ACTOR.metadata("*").replace(/:metadata$/, ""), + ); + const actorIds = keys.map((key) => key.split(":")[1]); + + const actors: Actor[] = []; + for (const actorId of actorIds) { + const metadataStr = await this.#redis.get(KEYS.ACTOR.metadata(actorId)); + + if (metadataStr) { + const metadata = JSON.parse(metadataStr); + actors.push({ + id: actorId, + name: metadata.name, + tags: metadata.tags, + }); + } + } + + 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)); + + if (!metadataStr) { + return undefined; + } + + 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}`; + } + + #getTagIndexKey(name: string, tagKey: string, tagValue: string): string { + return `actor_tag:${name}:${tagKey}:${tagValue}`; + } } function buildActorEndpoint(baseUrl: string, actorId: string) { return `${baseUrl}/actors/${actorId}`; } + diff --git a/packages/drivers/redis/src/mod.ts b/packages/drivers/redis/src/mod.ts new file mode 100644 index 000000000..c6fd7f816 --- /dev/null +++ b/packages/drivers/redis/src/mod.ts @@ -0,0 +1,3 @@ +export { RedisActorDriver } from "./actor"; +export { RedisManagerDriver } from "./manager"; +export { RedisCoordinateDriver } from "./coordinate"; \ No newline at end of file diff --git a/packages/drivers/redis/tests/driver-tests.test.ts b/packages/drivers/redis/tests/driver-tests.test.ts new file mode 100644 index 000000000..e3c052abb --- /dev/null +++ b/packages/drivers/redis/tests/driver-tests.test.ts @@ -0,0 +1,118 @@ +process.env._LOG_LEVEL="DEBUG"; +import { + runDriverTests, + createTestRuntime, +} from "@actor-core/driver-test-suite"; +import { RedisActorDriver, RedisCoordinateDriver, RedisManagerDriver } from "../src/mod"; +import Redis from "ioredis"; +import { promisify } from "node:util"; +import { exec as execCallback } from "node:child_process"; +import { expect, test } from "vitest"; +import getPort from "get-port"; + +const exec = promisify(execCallback); + +async function startValkeyContainer(): Promise<{ + port: number; + containerId: string; +}> { + const containerName = `valkey-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const port = await getPort(); + + const { stdout } = await exec( + `docker run --rm -d --name ${containerName} -p ${port}:6379 valkey/valkey:latest`, + ); + const containerId = stdout.trim(); + + // Wait for the port to be available using a simple TCP probe + const maxRetries = 10; + const retryDelayMs = 100; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // Use a simple command to check if the port is open + await exec(`nc -z localhost ${port}`); + // Port is available, container is ready + break; + } catch (error) { + if (attempt === maxRetries) { + await stopValkeyContainer(containerId).catch(() => {}); + throw new Error( + `Valkey container port ${port} never became available after ${maxRetries} attempts`, + ); + } + + // Wait before trying again + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + } + } + + return { port, containerId }; +} + +async function stopValkeyContainer(containerId: string): Promise { + try { + await exec(`docker stop ${containerId}`); + } catch (error) { + throw new Error(`Failed to stop container ${containerId}: ${error}`); + } +} + +runDriverTests({ + // Causes odd connectoin issues when disabled + useRealTimers: true, + async start(appPath: string) { + return await createTestRuntime(appPath, async (app) => { + const { port, containerId } = await startValkeyContainer(); + + // Create a new Redis client for this test (we still use ioredis for client) + const redisClient = new Redis({ + host: "localhost", + port, + // Use a random db number to avoid conflicts + db: Math.floor(Math.random() * 15), + // Add a prefix for additional isolation + keyPrefix: `test-${Date.now()}-${Math.floor(Math.random() * 10000)}:`, + }); + + return { + actorDriver: new RedisActorDriver(redisClient), + managerDriver: new RedisManagerDriver(redisClient, app), + coordinateDriver: new RedisCoordinateDriver(redisClient), + async cleanup() { + // TODO: This causes an error + //await redisClient.quit(); + await stopValkeyContainer(containerId); + }, + }; + }); + }, +}); + +// Test to verify that the Docker container management works correctly +test("Valkey container starts and stops properly", async () => { + const { port, containerId } = await startValkeyContainer(); + + // Check if Valkey is accessible + const redis = new Redis({ port, host: "localhost" }); + await redis.set("test-key", "test-value"); + const value = await redis.get("test-key"); + expect(value).toBe("test-value"); + + await redis.quit(); + await stopValkeyContainer(containerId); + + // Verify the container is stopped + try { + const newRedis = new Redis({ + port, + host: "localhost", + connectTimeout: 1000, + }); + await newRedis.connect(); + await newRedis.quit(); + throw new Error("Valkey connection should have failed"); + } catch (error) { + // Expected to fail since the container should be stopped + expect(error).toBeDefined(); + } +}); diff --git a/packages/drivers/redis/tsconfig.json b/packages/drivers/redis/tsconfig.json index accb9677a..043f499c0 100644 --- a/packages/drivers/redis/tsconfig.json +++ b/packages/drivers/redis/tsconfig.json @@ -5,5 +5,5 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*"] } diff --git a/packages/misc/driver-test-suite/fixtures/apps/counter.ts b/packages/misc/driver-test-suite/fixtures/apps/counter.ts new file mode 100644 index 000000000..59a418315 --- /dev/null +++ b/packages/misc/driver-test-suite/fixtures/apps/counter.ts @@ -0,0 +1,18 @@ +import { actor, setup } from "actor-core"; + +const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + c.broadcast("newCount", c.state.count); + return c.state.count; + }, + }, +}); + +export const app = setup({ + actors: { counter }, +}); + +export type App = typeof app; diff --git a/packages/misc/driver-test-suite/fixtures/apps/scheduled.ts b/packages/misc/driver-test-suite/fixtures/apps/scheduled.ts new file mode 100644 index 000000000..d586dd53e --- /dev/null +++ b/packages/misc/driver-test-suite/fixtures/apps/scheduled.ts @@ -0,0 +1,35 @@ +import { actor, setup } from "actor-core"; + +const scheduled = actor({ + state: { + lastRun: 0, + scheduledCount: 0, + }, + actions: { + scheduleTask: (c, delayMs: number) => { + const timestamp = Date.now() + delayMs; + c.schedule.at(timestamp, "onScheduledTask"); + return timestamp; + }, + getLastRun: (c) => { + return c.state.lastRun; + }, + getScheduledCount: (c) => { + return c.state.scheduledCount; + }, + onScheduledTask: (c) => { + c.state.lastRun = Date.now(); + c.state.scheduledCount++; + c.broadcast("scheduled", { + time: c.state.lastRun, + count: c.state.scheduledCount, + }); + }, + }, +}); + +export const app = setup({ + actors: { scheduled }, +}); + +export type App = typeof app; diff --git a/packages/misc/driver-test-suite/package.json b/packages/misc/driver-test-suite/package.json new file mode 100644 index 000000000..e42a17967 --- /dev/null +++ b/packages/misc/driver-test-suite/package.json @@ -0,0 +1,41 @@ +{ + "name": "@actor-core/driver-test-suite", + "version": "0.7.9", + "files": [ + "src", + "dist", + "package.json" + ], + "type": "module", + "exports": { + "import": { + "types": "./dist/mod.d.ts", + "default": "./dist/mod.js" + }, + "require": { + "types": "./dist/mod.d.cts", + "default": "./dist/mod.cjs" + } + }, + "sideEffects": false, + "scripts": { + "build": "tsup src/mod.ts", + "check-types": "tsc --noEmit" + }, + "peerDependencies": { + "actor-core": "workspace:*" + }, + "devDependencies": { + "tsup": "^8.4.0", + "typescript": "^5.7.3" + }, + "dependencies": { + "@hono/node-server": "^1.14.0", + "@hono/node-ws": "^1.1.1", + "@types/node": "^22.13.1", + "actor-core": "workspace:*", + "bundle-require": "^5.1.0", + "get-port": "^7.0.0", + "vitest": "^3.1.1" + } +} diff --git a/packages/misc/driver-test-suite/src/log.ts b/packages/misc/driver-test-suite/src/log.ts new file mode 100644 index 000000000..ccc558d59 --- /dev/null +++ b/packages/misc/driver-test-suite/src/log.ts @@ -0,0 +1,7 @@ +import { getLogger } from "actor-core/log"; + +export const LOGGER_NAME = "driver-test-suite"; + +export function logger() { + return getLogger(LOGGER_NAME); +} diff --git a/packages/misc/driver-test-suite/src/mod.ts b/packages/misc/driver-test-suite/src/mod.ts new file mode 100644 index 000000000..cdcdc565d --- /dev/null +++ b/packages/misc/driver-test-suite/src/mod.ts @@ -0,0 +1,126 @@ +import { serve as honoServe } from "@hono/node-server"; +import { + ActorDriver, + CoordinateDriver, + DriverConfig, + ManagerDriver, +} from "actor-core/driver-helpers"; +import { runActorDriverTests, waitFor } from "./tests/actor-driver"; +import { runManagerDriverTests } from "./tests/manager-driver"; +import { describe } from "vitest"; +import { + type ActorCoreApp, + CoordinateTopology, + StandaloneTopology, +} from "actor-core"; +import { createNodeWebSocket, type NodeWebSocket } from "@hono/node-ws"; +import invariant from "invariant"; +import getPort from "get-port"; +import { bundleRequire } from "bundle-require"; + +export interface DriverTestConfig { + /** Deploys an app and returns the connection endpoint. */ + start(appPath: string): Promise; + + /** + * If we're testing with an external system, we should use real timers + * instead of Vitest's mocked timers. + **/ + useRealTimers?: boolean; + + /** Cloudflare Workers has some bugs with cleanup. */ + HACK_skipCleanupNet?: boolean; +} + +export interface DriverDeployOutput { + endpoint: string; + + /** Cleans up the test. */ + cleanup(): Promise; +} + +/** Runs all Vitest tests against the provided drivers. */ +export function runDriverTests(driverTestConfig: DriverTestConfig) { + describe("driver tests", () => { + runActorDriverTests(driverTestConfig); + runManagerDriverTests(driverTestConfig); + }); +} + +/** + * Re-export the waitFor helper for use in other tests. + * This function handles waiting in tests, using either real timers or mocked timers + * based on the driverTestConfig.useRealTimers setting. + */ +export { waitFor }; + +/** + * Helper function to adapt the drivers to the Node.js runtime for tests. + * + * This is helpful for drivers that run in-process as opposed to drivers that rely on external tools. + */ +export async function createTestRuntime( + appPath: string, + driverFactory: (app: ActorCoreApp) => Promise<{ + actorDriver: ActorDriver; + managerDriver: ManagerDriver; + coordinateDriver?: CoordinateDriver; + cleanup?: () => Promise; + }>, +): Promise { + const { + mod: { app }, + } = await bundleRequire({ + filepath: appPath, + }); + + // Build drivers + const { + actorDriver, + managerDriver, + coordinateDriver, + cleanup: driverCleanup, + } = await driverFactory(app); + + // Build driver config + let injectWebSocket: NodeWebSocket["injectWebSocket"] | undefined; + const config: DriverConfig = { + drivers: { + actor: actorDriver, + manager: managerDriver, + coordinate: coordinateDriver, + }, + getUpgradeWebSocket: (app) => { + const webSocket = createNodeWebSocket({ app }); + injectWebSocket = webSocket.injectWebSocket; + return webSocket.upgradeWebSocket; + }, + }; + + // Build topology + const topology = coordinateDriver + ? new CoordinateTopology(app.config, config) + : new StandaloneTopology(app.config, config); + if (!injectWebSocket) throw new Error("injectWebSocket not defined"); + + // Start server + const port = await getPort(); + const server = honoServe({ + fetch: topology.router.fetch, + hostname: "127.0.0.1", + port, + }); + invariant(injectWebSocket !== undefined, "should have injectWebSocket"); + injectWebSocket(server); + + // Cleanup + const cleanup = async () => { + // Stop server + await new Promise((resolve) => server.close(() => resolve(undefined))); + + // Extra cleanup + await driverCleanup?.(); + }; + + return { endpoint: `http://127.0.0.1:${port}`, cleanup }; +} diff --git a/packages/misc/driver-test-suite/src/tests/actor-driver.ts b/packages/misc/driver-test-suite/src/tests/actor-driver.ts new file mode 100644 index 000000000..9e1b20d63 --- /dev/null +++ b/packages/misc/driver-test-suite/src/tests/actor-driver.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, vi } from "vitest"; +import type { DriverTestConfig } from "@/mod"; +import { setupDriverTest } from "@/utils"; +import { resolve } from "node:path"; +import type { App as CounterApp } from "../../fixtures/apps/counter"; +import type { App as ScheduledApp } from "../../fixtures/apps/scheduled"; + +/** + * Waits for the specified time, using either real setTimeout or vi.advanceTimersByTime + * based on the driverTestConfig. + */ +export async function waitFor( + driverTestConfig: DriverTestConfig, + ms: number, +): Promise { + if (driverTestConfig.useRealTimers) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } else { + vi.advanceTimersByTime(ms); + return Promise.resolve(); + } +} +export function runActorDriverTests(driverTestConfig: DriverTestConfig) { + describe("Actor Driver Tests", () => { + describe("State Persistence", () => { + test("persists state between actor instances", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Create instance and increment + const counterInstance = await client.counter.get(); + 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 persistedCount = await sameInstance.increment(3); + expect(persistedCount).toBe(8); // 5 + 3 = 8 + }); + + test("maintains separate state between different actor IDs", async () => { + const { client } = await setupDriverTest( + 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" }, + }); + + const countOne = await counterOneRefresh.increment(0); // Get current value + const countTwo = await counterTwoRefresh.increment(0); // Get current value + + expect(countOne).toBe(10); + expect(countTwo).toBe(20); + }); + }); + + describe("Actor Scheduling", () => { + test("schedules and executes tasks", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/scheduled.ts"), + ); + + // Get the scheduled actor + const scheduledActor = await client.scheduled.get(); + + // 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 waitFor(driverTestConfig, 150); + + // Verify the scheduled task ran + const count = await scheduledActor.getScheduledCount(); + expect(count).toBe(1); + + const lastRun = await scheduledActor.getLastRun(); + expect(lastRun).toBeGreaterThan(0); + }); + + test("schedules multiple tasks correctly", async () => { + const { client } = await setupDriverTest( + 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); + }); + }); + }); +} diff --git a/packages/misc/driver-test-suite/src/tests/manager-driver.ts b/packages/misc/driver-test-suite/src/tests/manager-driver.ts new file mode 100644 index 000000000..7dbfca7c0 --- /dev/null +++ b/packages/misc/driver-test-suite/src/tests/manager-driver.ts @@ -0,0 +1,384 @@ +import { describe, test, expect } from "vitest"; +import type { DriverTestConfig } from "@/mod"; +import { setupDriverTest } from "@/utils"; +import { resolve } from "node:path"; +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 () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Basic get() with no parameters creates a default actor + const counterA = await client.counter.get(); + await counterA.increment(5); + + // Get the same actor again to verify state persisted + const counterAAgain = await client.counter.get(); + 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" }, + }); + + await counterB.increment(10); + const countB = await counterB.increment(0); + expect(countB).toBe(10); + }); + + test("create() - always creates a new actor", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Create with basic options + const counterA = await client.counter.create({ + create: { + tags: { id: "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({ + create: { + tags: { id: "explicit-create" }, + }, + }); + await counterADuplicate.increment(1); + + // If we get here, the driver allows ID overwrites + // Verify that state was reset or overwritten + const newCount = await counterADuplicate.increment(0); + expect(newCount).toBe(1); // Not 8 (7+1) if it's a new instance + } catch (error) { + // This is also valid behavior if the driver prevents ID conflicts + // No assertion needed + } + + // 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 + }, + }); + + await counterB.increment(3); + const countB = await counterB.increment(0); + expect(countB).toBe(3); + }); + }); + + describe("Connection Options", () => { + test("noCreate option prevents actor creation", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Try to get a nonexistent actor with noCreate + const nonexistentId = `nonexistent-${Date.now()}`; + + // Should fail when actor doesn't exist + let error: unknown; + try { + await client.counter.get({ + tags: { id: nonexistentId }, + noCreate: true, + }); + } catch (err) { + error = err; + } + + // Verify we got an error + expect(error).toBeTruthy(); + + // Create the actor + const counter = await client.counter.create({ + create: { + tags: { id: nonexistentId }, + }, + }); + await counter.increment(3); + + // Now noCreate should work since the actor exists + const retrievedCounter = await client.counter.get({ + tags: { id: nonexistentId }, + noCreate: true, + }); + + const count = await retrievedCounter.increment(0); + expect(count).toBe(3); + }); + + test("connection params are passed to actors", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Create an actor with connection params + // 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({ + params: { + userId: "user-123", + authToken: "token-abc", + settings: { increment: 5 }, + }, + }); + + await counter.increment(1); + const count = await counter.increment(0); + expect(count).toBe(1); + }); + }); + + describe("Actor Creation & Retrieval", () => { + test("creates and retrieves actors by ID", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Create a unique ID for this test + const uniqueId = `test-counter-${Date.now()}`; + + // Create actor with specific ID + const counter = await client.counter.get({ + tags: { id: uniqueId }, + }); + await counter.increment(10); + + // Retrieve the same actor by ID and verify state + const retrievedCounter = await client.counter.get({ + tags: { id: uniqueId }, + }); + const count = await retrievedCounter.increment(0); // Get current value + expect(count).toBe(10); + }); + + // TODO: Correctly test region for each provider + //test("creates and retrieves actors with region", async () => { + // const { client } = await setupDriverTest( + // driverTestConfig, + // resolve(__dirname, "../fixtures/apps/counter.ts"), + // ); + // + // // Create actor with a specific region + // const counter = await client.counter.create({ + // create: { + // tags: { id: "metadata-test", purpose: "testing" }, + // region: "test-region", + // }, + // }); + // + // // Set state to identify this specific instance + // await counter.increment(42); + // + // // Retrieve by ID (since metadata is not used for retrieval) + // const retrievedCounter = await client.counter.get({ + // tags: { id: "metadata-test" }, + // }); + // + // // Verify it's the same instance + // const count = await retrievedCounter.increment(0); + // expect(count).toBe(42); + //}); + }); + + describe("Tag Matching", () => { + test("finds actors with equal or superset of specified tags", async () => { + const { client } = await setupDriverTest( + 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" }, + }); + 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" }, + }); + 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" }, + }); + 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); + }); + + test("no tags match actors with tags", async () => { + const { client } = await setupDriverTest( + 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); + + // Should match when searching with no tags + const noTagsCounter = await client.counter.get(); + const count = await noTagsCounter.increment(0); + + // Should have matched existing actor + expect(count).toBe(15); + }); + + test("actors with tags match actors with no tags", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Create a counter with no tags + const noTagsCounter = await client.counter.get(); + await noTagsCounter.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); + + // Should be a new counter, not the one created above + expect(taggedCount).toBe(0); + }); + + test("specifying different tags for get and create results in the expected tags", async () => { + const { client } = await setupDriverTest( + 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" } }, + }); + 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" }, + create: { + tags: { type: "secondary", priority: "low", temp: "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" }, + }); + const notFoundCount = await notFound.increment(0); + expect(notFoundCount).toBe(0); // New counter + }); + }); + + describe("Multiple Actor Instances", () => { + test("creates multiple actor instances of the same type", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // 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" }, + }); + + // Set different states + await instance1.increment(1); + await instance2.increment(2); + 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" }, + }); + + // Verify separate state + expect(await retrieved1.increment(0)).toBe(1); + expect(await retrieved2.increment(0)).toBe(2); + expect(await retrieved3.increment(0)).toBe(3); + }); + + test("handles default instance with no explicit ID", async () => { + const { client } = await setupDriverTest( + driverTestConfig, + resolve(__dirname, "../fixtures/apps/counter.ts"), + ); + + // Get default instance (no ID specified) + const defaultCounter = await client.counter.get(); + + // Set state + await defaultCounter.increment(5); + + // Get default instance again + const sameDefaultCounter = await client.counter.get(); + + // Verify state is maintained + const count = await sameDefaultCounter.increment(0); + expect(count).toBe(5); + }); + }); + }); +} diff --git a/packages/misc/driver-test-suite/src/utils.ts b/packages/misc/driver-test-suite/src/utils.ts new file mode 100644 index 000000000..3ebd78a2d --- /dev/null +++ b/packages/misc/driver-test-suite/src/utils.ts @@ -0,0 +1,29 @@ +import type { ActorCoreApp } from "actor-core"; +import { onTestFinished, vi } from "vitest"; +import { createClient, type Client } from "actor-core/client"; +import type { DriverTestConfig } from "./mod"; + +export async function setupDriverTest>( + driverTestConfig: DriverTestConfig, + appPath: string, +): Promise<{ + client: Client; +}> { + if (!driverTestConfig.useRealTimers) { + vi.useFakeTimers(); + } + + // Build drivers + const { endpoint, cleanup } = await driverTestConfig.start(appPath); + onTestFinished(async () => cleanup()); + + // Create client + const client = createClient(endpoint); + if (!driverTestConfig.HACK_skipCleanupNet) { + onTestFinished(async () => await client.dispose()); + } + + return { + client, + }; +} diff --git a/packages/misc/driver-test-suite/tsconfig.json b/packages/misc/driver-test-suite/tsconfig.json new file mode 100644 index 000000000..accb9677a --- /dev/null +++ b/packages/misc/driver-test-suite/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/packages/misc/driver-test-suite/tsup.config.bundled_6lmockkaxzl.mjs b/packages/misc/driver-test-suite/tsup.config.bundled_6lmockkaxzl.mjs new file mode 100644 index 000000000..103df5517 --- /dev/null +++ b/packages/misc/driver-test-suite/tsup.config.bundled_6lmockkaxzl.mjs @@ -0,0 +1,22 @@ +// ../../../tsup.base.ts +var tsup_base_default = { + target: "node16", + platform: "node", + format: ["cjs", "esm"], + sourcemap: true, + clean: true, + dts: true, + minify: false, + // IMPORTANT: Splitting is required to fix a bug with ESM (https://github.com/egoist/tsup/issues/992#issuecomment-1763540165) + splitting: true, + skipNodeModulesBundle: true, + publicDir: true +}; + +// tsup.config.ts +import { defineConfig } from "tsup"; +var tsup_config_default = defineConfig(tsup_base_default); +export { + tsup_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vLi4vdHN1cC5iYXNlLnRzIiwgInRzdXAuY29uZmlnLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS90c3VwLmJhc2UudHNcIjtjb25zdCBfX2luamVjdGVkX2Rpcm5hbWVfXyA9IFwiL1VzZXJzL25hdGhhbi9yaXZldC9hY3Rvci1jb3JlXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS90c3VwLmJhc2UudHNcIjtpbXBvcnQgdHlwZSB7IE9wdGlvbnMgfSBmcm9tIFwidHN1cFwiO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHRhcmdldDogXCJub2RlMTZcIixcblx0cGxhdGZvcm06IFwibm9kZVwiLFxuXHRmb3JtYXQ6IFtcImNqc1wiLCBcImVzbVwiXSxcblx0c291cmNlbWFwOiB0cnVlLFxuXHRjbGVhbjogdHJ1ZSxcblx0ZHRzOiB0cnVlLFxuXHRtaW5pZnk6IGZhbHNlLFxuXHQvLyBJTVBPUlRBTlQ6IFNwbGl0dGluZyBpcyByZXF1aXJlZCB0byBmaXggYSBidWcgd2l0aCBFU00gKGh0dHBzOi8vZ2l0aHViLmNvbS9lZ29pc3QvdHN1cC9pc3N1ZXMvOTkyI2lzc3VlY29tbWVudC0xNzYzNTQwMTY1KVxuXHRzcGxpdHRpbmc6IHRydWUsXG5cdHNraXBOb2RlTW9kdWxlc0J1bmRsZTogdHJ1ZSxcblx0cHVibGljRGlyOiB0cnVlLFxufSBzYXRpc2ZpZXMgT3B0aW9ucztcbiIsICJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9kcml2ZXJzL3JlZGlzL3RzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIi9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9kcml2ZXJzL3JlZGlzXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9Vc2Vycy9uYXRoYW4vcml2ZXQvYWN0b3ItY29yZS9wYWNrYWdlcy9kcml2ZXJzL3JlZGlzL3RzdXAuY29uZmlnLnRzXCI7aW1wb3J0IGRlZmF1bHRDb25maWcgZnJvbSBcIi4uLy4uLy4uL3RzdXAuYmFzZS50c1wiO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSBcInRzdXBcIjtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKGRlZmF1bHRDb25maWcpO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUVBLElBQU8sb0JBQVE7QUFBQSxFQUNkLFFBQVE7QUFBQSxFQUNSLFVBQVU7QUFBQSxFQUNWLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQSxFQUNyQixXQUFXO0FBQUEsRUFDWCxPQUFPO0FBQUEsRUFDUCxLQUFLO0FBQUEsRUFDTCxRQUFRO0FBQUE7QUFBQSxFQUVSLFdBQVc7QUFBQSxFQUNYLHVCQUF1QjtBQUFBLEVBQ3ZCLFdBQVc7QUFDWjs7O0FDYkEsU0FBUyxvQkFBb0I7QUFFN0IsSUFBTyxzQkFBUSxhQUFhLGlCQUFhOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/packages/misc/driver-test-suite/tsup.config.ts b/packages/misc/driver-test-suite/tsup.config.ts new file mode 100644 index 000000000..677cffb7b --- /dev/null +++ b/packages/misc/driver-test-suite/tsup.config.ts @@ -0,0 +1,4 @@ +import defaultConfig from "../../../tsup.base.ts"; +import { defineConfig } from "tsup"; + +export default defineConfig(defaultConfig); diff --git a/packages/misc/driver-test-suite/turbo.json b/packages/misc/driver-test-suite/turbo.json new file mode 100644 index 000000000..95960709b --- /dev/null +++ b/packages/misc/driver-test-suite/turbo.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"] +} diff --git a/packages/misc/driver-test-suite/vitest.config.ts b/packages/misc/driver-test-suite/vitest.config.ts new file mode 100644 index 000000000..c5c926c2b --- /dev/null +++ b/packages/misc/driver-test-suite/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import { resolve } from "path"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); \ No newline at end of file diff --git a/packages/platforms/cloudflare-workers/package.json b/packages/platforms/cloudflare-workers/package.json index 29bc7c203..f7aa94461 100644 --- a/packages/platforms/cloudflare-workers/package.json +++ b/packages/platforms/cloudflare-workers/package.json @@ -23,20 +23,26 @@ "sideEffects": false, "scripts": { "build": "tsup src/mod.ts", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run tests" }, "peerDependencies": { "actor-core": "*" }, "devDependencies": { + "@actor-core/driver-test-suite": "workspace:*", "@cloudflare/workers-types": "^4.20250129.0", + "@types/invariant": "^2", "actor-core": "workspace:*", + "get-port": "^7.0.0", "tsup": "^8.4.0", "typescript": "^5.5.2", + "vitest": "^3.1.1", "wrangler": "^3.101.0" }, "dependencies": { "hono": "^4.7.2", + "invariant": "^2.2.4", "zod": "^3.24.2" } } diff --git a/packages/platforms/cloudflare-workers/src/actor_driver.ts b/packages/platforms/cloudflare-workers/src/actor_driver.ts index 21f2827b0..215df712b 100644 --- a/packages/platforms/cloudflare-workers/src/actor_driver.ts +++ b/packages/platforms/cloudflare-workers/src/actor_driver.ts @@ -1,7 +1,5 @@ import { ActorDriver, - KvKey, - KvValue, AnyActorInstance, } from "actor-core/driver-helpers"; import invariant from "invariant"; @@ -52,46 +50,15 @@ export class CloudflareWorkersActorDriver implements ActorDriver { return { ctx: state.ctx, env: state.env }; } - async kvGet(actorId: string, key: KvKey): Promise { - return await this.#getDOCtx(actorId).storage.get(this.#serializeKey(key)); + async readPersistedData(actorId: string): Promise { + return await this.#getDOCtx(actorId).storage.get("persisted_data"); } - async kvGetBatch( - actorId: string, - keys: KvKey[], - ): Promise<(KvValue | undefined)[]> { - const resultMap = await this.#getDOCtx(actorId).storage.get( - keys.map(this.#serializeKey), - ); - return keys.map((key) => resultMap.get(this.#serializeKey(key))); - } - - async kvPut(actorId: string, key: KvKey, value: KvValue): Promise { - await this.#getDOCtx(actorId).storage.put(this.#serializeKey(key), value); - } - - async kvPutBatch( - actorId: string, - entries: [KvKey, KvValue][], - ): Promise { - await this.#getDOCtx(actorId).storage.put( - Object.fromEntries(entries.map(([k, v]) => [this.#serializeKey(k), v])), - ); - } - - async kvDelete(actorId: string, key: KvKey): Promise { - await this.#getDOCtx(actorId).storage.delete(this.#serializeKey(key)); - } - - async kvDeleteBatch(actorId: string, keys: KvKey[]): Promise { - await this.#getDOCtx(actorId).storage.delete(keys.map(this.#serializeKey)); + async writePersistedData(actorId: string, data: unknown): Promise { + await this.#getDOCtx(actorId).storage.put("persisted_data", data); } async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { await this.#getDOCtx(actor.id).storage.setAlarm(timestamp); } - - #serializeKey(key: KvKey): string { - return JSON.stringify(key); - } } diff --git a/packages/platforms/cloudflare-workers/src/manager_driver.ts b/packages/platforms/cloudflare-workers/src/manager_driver.ts index d1d178b76..e39784cf7 100644 --- a/packages/platforms/cloudflare-workers/src/manager_driver.ts +++ b/packages/platforms/cloudflare-workers/src/manager_driver.ts @@ -6,6 +6,38 @@ import type { GetActorOutput, } from "actor-core/driver-helpers"; import { Bindings } from "./mod"; +import { logger } from "./log"; + +// Define metadata type for CloudflareKV +interface KVMetadata { + actorId: string; +} + +// 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 constants similar to Redis implementation +const KEYS = { + ACTOR: { + // Combined key for actor metadata (name and tags) + 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)}:`, + }, +}; export class CloudflareWorkersManagerDriver implements ManagerDriver { async getForId({ @@ -17,36 +49,20 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { > { if (!c) throw new Error("Missing Hono context"); - // TODO: Error handling - - //// Validate actor - //if ((res.actor.tags as ActorTags).access !== "public") { - // // TODO: Throw 404 that matches the 404 from Fern if the actor is not found - // throw new Error(`Actor with ID ${query.getForId.actorId} is private`); - //} - //if (res.actor.destroyedAt) { - // throw new Error( - // `Actor with ID ${query.getForId.actorId} already destroyed`, - // ); - //} - // - //return res.actor; - - // Get tags from KV - const name = await c.env.ACTOR_KV.get(`actor:${actorId}:name`); - const tagsStr = await c.env.ACTOR_KV.get(`actor:${actorId}:tags`); + // Get actor metadata from KV (combined name and tags) + const actorData = (await c.env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { + type: "json", + })) as ActorData | null; // If the actor doesn't exist, return undefined - if (!name || !tagsStr) { + if (!actorData) { return undefined; } - const tags = JSON.parse(tagsStr); - return { endpoint: buildActorEndpoint(baseUrl, actorId), - name, - tags, + name: actorData.name, + tags: actorData.tags, }; } @@ -59,26 +75,92 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { 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); + } - // TODO: use an inverse tree for correct tag looups - - const actorId = await c.env.ACTOR_KV.get( - `actor:tags:${name}:${JSON.stringify(tags)}:id`, - ); - if (actorId) { - // Get the complete tags for the actor - const tagsStr = await c.env.ACTOR_KV.get(`actor:${actorId}:tags`); - - if (!tagsStr) throw new Error("Missing actor for tags."); - - const actorTags = JSON.parse(tagsStr); + // 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(`getWithTags: found actors with tag ${tagKey}=${tagValue}`, { + count: actorIdsWithTag.length, + }); + + // 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; + } + } + } - return { - endpoint: buildActorEndpoint(baseUrl, actorId), + // 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, name, - tags: actorTags, - }; + tags, + }); + return this.#buildActorOutput(c, baseUrl, actorId); } + + log.debug("getWithTags: no actor found with matching tags"); return undefined; } @@ -90,36 +172,72 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { region, }: CreateActorInput<{ Bindings: Bindings }>): Promise { if (!c) throw new Error("Missing Hono context"); + const log = logger(); - const actorId = c.env.ACTOR_DO.newUniqueId({ + const durableId = c.env.ACTOR_DO.newUniqueId({ jurisdiction: region as DurableObjectJurisdiction | undefined, }); + const actorId = durableId.toString(); // Init actor - const actor = c.env.ACTOR_DO.get(actorId); + const actor = c.env.ACTOR_DO.get(durableId); await actor.initialize({ name, tags, }); - // Save tags (after init so the actor is ready) + // Store combined actor metadata (name and tags) + const actorData: ActorData = { name, tags }; await c.env.ACTOR_KV.put( - `actor:tags:${name}:${JSON.stringify(tags)}:id`, - actorId.toString(), + KEYS.ACTOR.metadata(actorId), + JSON.stringify(actorData), ); - // Also store the tags indexed by actor ID - await c.env.ACTOR_KV.put(`actor:${actorId}:name`, name); - await c.env.ACTOR_KV.put(`actor:${actorId}:tags`, JSON.stringify(tags)); + // 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 }, + ); + } return { - endpoint: buildActorEndpoint(baseUrl, actorId.toString()), + endpoint: buildActorEndpoint(baseUrl, actorId), name, tags, }; } + + // Helper method to build actor output from an ID + async #buildActorOutput( + c: any, + baseUrl: string, + actorId: string, + ): Promise { + const actorData = (await c.env.ACTOR_KV.get(KEYS.ACTOR.metadata(actorId), { + type: "json", + })) as ActorData | null; + + if (!actorData) { + return undefined; + } + + return { + endpoint: buildActorEndpoint(baseUrl, actorId), + name: actorData.name, + tags: actorData.tags, + }; + } } function buildActorEndpoint(baseUrl: string, actorId: string) { return `${baseUrl}/actors/${actorId}`; } + diff --git a/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts new file mode 100644 index 000000000..2df028275 --- /dev/null +++ b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts @@ -0,0 +1,176 @@ +import { runDriverTests } from "@actor-core/driver-test-suite"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { spawn, exec } from "node:child_process"; +import crypto from "node:crypto"; +import getPort from "get-port"; +import { promisify } from "node:util"; + +const execPromise = promisify(exec); + +// Bypass createTestRuntime by providing an endpoint directly +runDriverTests({ + useRealTimers: true, + HACK_skipCleanupNet: true, + async start(appPath: string) { + // Get an available port + const port = await getPort(); + + // Create a temporary directory for the test + const uuid = crypto.randomUUID(); + const tmpDir = path.join(os.tmpdir(), `actor-core-cloudflare-test-${uuid}`); + await fs.mkdir(tmpDir, { recursive: true }); + + // Create package.json with workspace dependencies + const packageJson = { + name: "actor-core-test", + private: true, + version: "1.0.0", + type: "module", + scripts: { + start: `wrangler@4.8.0 dev --port ${port} --local`, + }, + dependencies: { + wrangler: "4.8.0", + "@actor-core/cloudflare-workers": "workspace:*", + "actor-core": "workspace:*", + }, + packageManager: + "yarn@4.7.0+sha512.5a0afa1d4c1d844b3447ee3319633797bcd6385d9a44be07993ae52ff4facabccafb4af5dcd1c2f9a94ac113e5e9ff56f6130431905884414229e284e37bb7c9", + }; + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + + // Disable PnP + const yarnPnp = "nodeLinker: node-modules"; + await fs.writeFile(path.join(tmpDir, ".yarnrc.yml"), yarnPnp); + + // Get the current workspace root path and link the workspace + const workspaceRoot = path.resolve(__dirname, "../../../.."); + await execPromise(`yarn link -A ${workspaceRoot}`, { cwd: tmpDir }); + + // Install deps + await execPromise("yarn install", { cwd: tmpDir }); + + // Create a wrangler.json file + const wranglerConfig = { + name: "actor-core-test", + main: "src/index.ts", + compatibility_date: "2025-01-29", + compatibility_flags: ["nodejs_compat"], + dev: { + port, + }, + migrations: [ + { + new_classes: ["ActorHandler"], + tag: "v1", + }, + ], + durable_objects: { + bindings: [ + { + class_name: "ActorHandler", + name: "ACTOR_DO", + }, + ], + }, + kv_namespaces: [ + { + binding: "ACTOR_KV", + id: "test", // Will be replaced with a mock in dev mode + }, + ], + observability: { + enabled: true, + }, + }; + await fs.writeFile( + path.join(tmpDir, "wrangler.json"), + JSON.stringify(wranglerConfig, null, 2), + ); + + // Create src directory + const srcDir = path.join(tmpDir, "src"); + await fs.mkdir(srcDir, { recursive: true }); + + // Write the index.ts file based on the app path + const indexContent = `import { createHandler } from "@actor-core/cloudflare-workers"; +import { app } from "${appPath.replace(/\.ts$/, "")}"; + +// Create handlers for Cloudflare Workers +const { handler, ActorHandler } = createHandler(app); + +// Export the handlers for Cloudflare +export { handler as default, ActorHandler }; +`; + await fs.writeFile(path.join(srcDir, "index.ts"), indexContent); + + // Start wrangler dev + const wranglerProcess = spawn( + "npx", + ["wrangler@4.8.0", "dev", "--port", port.toString(), "--local"], + { + cwd: tmpDir, + stdio: "pipe", + }, + ); + + // Wait for wrangler to start + await new Promise((resolve, reject) => { + let isResolved = false; + const timeout = setTimeout(() => { + if (!isResolved) { + isResolved = true; + wranglerProcess.kill(); + reject(new Error("Timeout waiting for wrangler to start")); + } + }, 30000); + + wranglerProcess.stdout?.on("data", (data) => { + const output = data.toString(); + if (output.includes(`Ready on http://localhost:${port}`)) { + if (!isResolved) { + isResolved = true; + clearTimeout(timeout); + resolve(); + } + } + }); + + wranglerProcess.stderr?.on("data", (data) => { + console.error(`wrangler error: ${data}`); + }); + + wranglerProcess.on("error", (error) => { + if (!isResolved) { + isResolved = true; + clearTimeout(timeout); + reject(error); + } + }); + + wranglerProcess.on("exit", (code) => { + if (!isResolved && code !== 0) { + isResolved = true; + clearTimeout(timeout); + reject(new Error(`wrangler exited with code ${code}`)); + } + }); + }); + + return { + endpoint: `http://localhost:${port}`, + async cleanup() { + // Shut down wrangler process + wranglerProcess.kill(); + + // Clean up temporary directory + await fs.rm(tmpDir, { recursive: true, force: true }); + }, + }; + }, +}); diff --git a/packages/platforms/cloudflare-workers/tsconfig.json b/packages/platforms/cloudflare-workers/tsconfig.json index 6f4d2f9d8..42b30b010 100644 --- a/packages/platforms/cloudflare-workers/tsconfig.json +++ b/packages/platforms/cloudflare-workers/tsconfig.json @@ -1,10 +1,10 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "types": ["@cloudflare/workers-types"], + "types": ["@cloudflare/workers-types", "node"], "paths": { "@/*": ["./src/*"] } }, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*"] } diff --git a/packages/platforms/cloudflare-workers/turbo.json b/packages/platforms/cloudflare-workers/turbo.json index 95960709b..ec4304315 100644 --- a/packages/platforms/cloudflare-workers/turbo.json +++ b/packages/platforms/cloudflare-workers/turbo.json @@ -1,4 +1,10 @@ { "$schema": "https://turbo.build/schema.json", - "extends": ["//"] + "extends": ["//"], + "tasks": { + "test": { + // Also build this package, since the tests depend on the output scripts + "dependsOn": ["^build", "check-types", "build"] + } + } } diff --git a/packages/platforms/cloudflare-workers/vitest.config.ts b/packages/platforms/cloudflare-workers/vitest.config.ts new file mode 100644 index 000000000..88747caae --- /dev/null +++ b/packages/platforms/cloudflare-workers/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); diff --git a/packages/platforms/rivet/package.json b/packages/platforms/rivet/package.json index 02e00e8d9..c607abe04 100644 --- a/packages/platforms/rivet/package.json +++ b/packages/platforms/rivet/package.json @@ -23,17 +23,21 @@ "sideEffects": false, "scripts": { "build": "tsup src/mod.ts", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run" }, "peerDependencies": { "actor-core": "*" }, "devDependencies": { + "@actor-core/driver-test-suite": "workspace:*", "@rivet-gg/actor-core": "^25.1.0", "@types/deno": "^2.0.0", + "@types/node": "^22.13.1", "actor-core": "workspace:*", "tsup": "^8.4.0", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^1.6.0" }, "dependencies": { "hono": "^4.7.0", diff --git a/packages/platforms/rivet/public/tsconfig.json b/packages/platforms/rivet/public/tsconfig.json index 230c4bd97..c39fc17c7 100644 --- a/packages/platforms/rivet/public/tsconfig.json +++ b/packages/platforms/rivet/public/tsconfig.json @@ -39,5 +39,6 @@ /* Skip type checking all .d.ts files. */ "skipLibCheck": true - } + }, + "include": ["src/**/*", "tests/**/*"] } diff --git a/packages/platforms/rivet/src/actor_driver.ts b/packages/platforms/rivet/src/actor_driver.ts index c5bf15ca5..19d02ae55 100644 --- a/packages/platforms/rivet/src/actor_driver.ts +++ b/packages/platforms/rivet/src/actor_driver.ts @@ -1,5 +1,5 @@ import type { ActorContext } from "@rivet-gg/actor-core"; -import type { ActorDriver, KvKey, KvValue, AnyActorInstance } from "actor-core/driver-helpers"; +import type { ActorDriver, AnyActorInstance } from "actor-core/driver-helpers"; export interface ActorDriverContext { ctx: ActorContext; @@ -16,35 +16,14 @@ export class RivetActorDriver implements ActorDriver { return { ctx: this.#ctx }; } - async kvGet(_actorId: string, key: KvKey): Promise { - return await this.#ctx.kv.get(key); + async readPersistedData(_actorId: string): Promise { + // Use "state" as the key for persisted data + return await this.#ctx.kv.get(["actor-core", "data"]); } - async kvGetBatch( - _actorId: string, - keys: KvKey[], - ): Promise<(KvValue | undefined)[]> { - const response = await this.#ctx.kv.getBatch(keys); - return keys.map((key) => response.get(key)); - } - - async kvPut(_actorId: string, key: KvKey, value: KvValue): Promise { - await this.#ctx.kv.put(key, value); - } - - async kvPutBatch( - _actorId: string, - entries: [KvKey, KvValue][], - ): Promise { - await this.#ctx.kv.putBatch(new Map(entries)); - } - - async kvDelete(_actorId: string, key: KvKey): Promise { - await this.#ctx.kv.delete(key); - } - - async kvDeleteBatch(_actorId: string, keys: KvKey[]): Promise { - await this.#ctx.kv.deleteBatch(keys); + async writePersistedData(_actorId: string, data: unknown): Promise { + // Use "state" as the key for persisted data + await this.#ctx.kv.put(["actor-core", "data"], data); } async setAlarm(actor: AnyActorInstance, timestamp: number): Promise { diff --git a/packages/platforms/rivet/tests/driver-tests.test.ts b/packages/platforms/rivet/tests/driver-tests.test.ts new file mode 100644 index 000000000..4863fad28 --- /dev/null +++ b/packages/platforms/rivet/tests/driver-tests.test.ts @@ -0,0 +1,22 @@ +// 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/tsconfig.json b/packages/platforms/rivet/tsconfig.json index 8f47fdccf..0c116dfe0 100644 --- a/packages/platforms/rivet/tsconfig.json +++ b/packages/platforms/rivet/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "types": ["deno"], + "types": ["deno", "node"], "paths": { "@/*": ["./src/*"] } diff --git a/yarn.lock b/yarn.lock index fd0e94c98..c7673009c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -73,11 +73,16 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/cloudflare-workers@workspace:packages/platforms/cloudflare-workers" dependencies: + "@actor-core/driver-test-suite": "workspace:*" "@cloudflare/workers-types": "npm:^4.20250129.0" + "@types/invariant": "npm:^2" actor-core: "workspace:*" + get-port: "npm:^7.0.0" hono: "npm:^4.7.2" + invariant: "npm:^2.2.4" tsup: "npm:^8.4.0" typescript: "npm:^5.5.2" + vitest: "npm:^3.1.1" wrangler: "npm:^3.101.0" zod: "npm:^3.24.2" peerDependencies: @@ -85,10 +90,29 @@ __metadata: languageName: unknown linkType: soft +"@actor-core/driver-test-suite@workspace:*, @actor-core/driver-test-suite@workspace:packages/misc/driver-test-suite": + version: 0.0.0-use.local + resolution: "@actor-core/driver-test-suite@workspace:packages/misc/driver-test-suite" + dependencies: + "@hono/node-server": "npm:^1.14.0" + "@hono/node-ws": "npm:^1.1.1" + "@types/node": "npm:^22.13.1" + actor-core: "workspace:*" + bundle-require: "npm:^5.1.0" + get-port: "npm:^7.0.0" + tsup: "npm:^8.4.0" + typescript: "npm:^5.7.3" + vitest: "npm:^3.1.1" + peerDependencies: + actor-core: "workspace:*" + languageName: unknown + linkType: soft + "@actor-core/file-system@workspace:*, @actor-core/file-system@workspace:^, @actor-core/file-system@workspace:packages/drivers/file-system": version: 0.0.0-use.local resolution: "@actor-core/file-system@workspace:packages/drivers/file-system" dependencies: + "@actor-core/driver-test-suite": "workspace:*" "@types/invariant": "npm:^2" "@types/node": "npm:^22.14.0" actor-core: "workspace:*" @@ -97,6 +121,7 @@ __metadata: invariant: "npm:^2.2.4" tsup: "npm:^8.4.0" typescript: "npm:^5.5.2" + vitest: "npm:^3.1.1" peerDependencies: actor-core: "*" languageName: unknown @@ -119,11 +144,13 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/memory@workspace:packages/drivers/memory" dependencies: + "@actor-core/driver-test-suite": "workspace:*" "@types/node": "npm:^22.13.1" actor-core: "workspace:*" hono: "npm:^4.7.0" tsup: "npm:^8.4.0" typescript: "npm:^5.5.2" + vitest: "npm:^3.1.1" peerDependencies: actor-core: "*" languageName: unknown @@ -169,6 +196,7 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/redis@workspace:packages/drivers/redis" dependencies: + "@actor-core/driver-test-suite": "workspace:*" "@types/node": "npm:^22.13.1" actor-core: "workspace:*" dedent: "npm:^1.5.3" @@ -177,6 +205,7 @@ __metadata: p-retry: "npm:^6.2.1" tsup: "npm:^8.4.0" typescript: "npm:^5.5.2" + vitest: "npm:^1.4.0" peerDependencies: actor-core: "workspace:*" languageName: unknown @@ -186,12 +215,15 @@ __metadata: version: 0.0.0-use.local resolution: "@actor-core/rivet@workspace:packages/platforms/rivet" dependencies: + "@actor-core/driver-test-suite": "workspace:*" "@rivet-gg/actor-core": "npm:^25.1.0" "@types/deno": "npm:^2.0.0" + "@types/node": "npm:^22.13.1" actor-core: "workspace:*" hono: "npm:^4.7.0" tsup: "npm:^8.4.0" typescript: "npm:^5.5.2" + vitest: "npm:^1.6.0" zod: "npm:^3.24.2" peerDependencies: actor-core: "*" @@ -1374,7 +1406,7 @@ __metadata: languageName: node linkType: hard -"@hono/node-server@npm:^1.13.8": +"@hono/node-server@npm:^1.13.8, @hono/node-server@npm:^1.14.0": version: 1.14.0 resolution: "@hono/node-server@npm:1.14.0" peerDependencies: @@ -1383,7 +1415,7 @@ __metadata: languageName: node linkType: hard -"@hono/node-ws@npm:^1.0.8": +"@hono/node-ws@npm:^1.0.8, @hono/node-ws@npm:^1.1.1": version: 1.1.1 resolution: "@hono/node-ws@npm:1.1.1" dependencies: @@ -1614,6 +1646,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/schemas@npm:29.6.3" + dependencies: + "@sinclair/typebox": "npm:^0.27.8" + checksum: 10c0/b329e89cd5f20b9278ae1233df74016ebf7b385e0d14b9f4c1ad18d096c4c19d1e687aa113a9c976b16ec07f021ae53dea811fb8c1248a50ac34fbe009fdf6be + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.2, @jridgewell/gen-mapping@npm:^0.3.5": version: 0.3.8 resolution: "@jridgewell/gen-mapping@npm:0.3.8" @@ -2567,6 +2608,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.27.8": + version: 0.27.8 + resolution: "@sinclair/typebox@npm:0.27.8" + checksum: 10c0/ef6351ae073c45c2ac89494dbb3e1f87cc60a93ce4cde797b782812b6f97da0d620ae81973f104b43c9b7eaa789ad20ba4f6a1359f1cc62f63729a55a7d22d4e + languageName: node + linkType: hard + "@sindresorhus/merge-streams@npm:^4.0.0": version: 4.0.0 resolution: "@sindresorhus/merge-streams@npm:4.0.0" @@ -2835,6 +2883,17 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/expect@npm:1.6.1" + dependencies: + "@vitest/spy": "npm:1.6.1" + "@vitest/utils": "npm:1.6.1" + chai: "npm:^4.3.10" + checksum: 10c0/278164b2a32a7019b443444f21111c5e32e4cadee026cae047ae2a3b347d99dca1d1fb7b79509c88b67dc3db19fa9a16265b7d7a8377485f7e37f7851e44495a + languageName: node + linkType: hard + "@vitest/expect@npm:2.1.9": version: 2.1.9 resolution: "@vitest/expect@npm:2.1.9" @@ -2915,6 +2974,17 @@ __metadata: languageName: node linkType: hard +"@vitest/runner@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/runner@npm:1.6.1" + dependencies: + "@vitest/utils": "npm:1.6.1" + p-limit: "npm:^5.0.0" + pathe: "npm:^1.1.1" + checksum: 10c0/36333f1a596c4ad85d42c6126cc32959c984d584ef28d366d366fa3672678c1a0f5e5c2e8717a36675b6620b57e8830f765d6712d1687f163ed0a8ebf23c87db + languageName: node + linkType: hard + "@vitest/runner@npm:2.1.9": version: 2.1.9 resolution: "@vitest/runner@npm:2.1.9" @@ -2935,6 +3005,17 @@ __metadata: languageName: node linkType: hard +"@vitest/snapshot@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/snapshot@npm:1.6.1" + dependencies: + magic-string: "npm:^0.30.5" + pathe: "npm:^1.1.1" + pretty-format: "npm:^29.7.0" + checksum: 10c0/68bbc3132c195ec37376469e4b183fc408e0aeedd827dffcc899aac378e9ea324825f0873062786e18f00e3da9dd8a93c9bb871c07471ee483e8df963cb272eb + languageName: node + linkType: hard + "@vitest/snapshot@npm:2.1.9": version: 2.1.9 resolution: "@vitest/snapshot@npm:2.1.9" @@ -2957,6 +3038,15 @@ __metadata: languageName: node linkType: hard +"@vitest/spy@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/spy@npm:1.6.1" + dependencies: + tinyspy: "npm:^2.2.0" + checksum: 10c0/5207ec0e7882819f0e0811293ae6d14163e26927e781bb4de7d40b3bd99c1fae656934c437bb7a30443a3e7e736c5bccb037bbf4436dbbc83d29e65247888885 + languageName: node + linkType: hard + "@vitest/spy@npm:2.1.9": version: 2.1.9 resolution: "@vitest/spy@npm:2.1.9" @@ -2975,6 +3065,18 @@ __metadata: languageName: node linkType: hard +"@vitest/utils@npm:1.6.1": + version: 1.6.1 + resolution: "@vitest/utils@npm:1.6.1" + dependencies: + diff-sequences: "npm:^29.6.3" + estree-walker: "npm:^3.0.3" + loupe: "npm:^2.3.7" + pretty-format: "npm:^29.7.0" + checksum: 10c0/0d4c619e5688cbc22a60c412719c6baa40376b7671bdbdc3072552f5c5a5ee5d24a96ea328b054018debd49e0626a5e3db672921b2c6b5b17b9a52edd296806a + languageName: node + linkType: hard + "@vitest/utils@npm:2.1.9": version: 2.1.9 resolution: "@vitest/utils@npm:2.1.9" @@ -3029,6 +3131,15 @@ __metadata: languageName: node linkType: hard +"acorn-walk@npm:^8.3.2": + version: 8.3.4 + resolution: "acorn-walk@npm:8.3.4" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10c0/76537ac5fb2c37a64560feaf3342023dadc086c46da57da363e64c6148dc21b57d49ace26f949e225063acb6fb441eabffd89f7a3066de5ad37ab3e328927c62 + languageName: node + linkType: hard + "acorn@npm:8.14.0": version: 8.14.0 resolution: "acorn@npm:8.14.0" @@ -3038,7 +3149,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.14.0, acorn@npm:^8.14.1, acorn@npm:^8.8.1": +"acorn@npm:^8.11.0, acorn@npm:^8.14.0, acorn@npm:^8.14.1, acorn@npm:^8.8.1": version: 8.14.1 resolution: "acorn@npm:8.14.1" bin: @@ -3136,6 +3247,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^5.0.0": + version: 5.2.0 + resolution: "ansi-styles@npm:5.2.0" + checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df + languageName: node + linkType: hard + "ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" @@ -3208,6 +3326,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^1.1.0": + version: 1.1.0 + resolution: "assertion-error@npm:1.1.0" + checksum: 10c0/25456b2aa333250f01143968e02e4884a34588a8538fbbf65c91a637f1dbfb8069249133cd2f4e530f10f624d206a664e7df30207830b659e9f5298b00a4099b + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -3578,6 +3703,21 @@ __metadata: languageName: node linkType: hard +"chai@npm:^4.3.10": + version: 4.5.0 + resolution: "chai@npm:4.5.0" + dependencies: + assertion-error: "npm:^1.1.0" + check-error: "npm:^1.0.3" + deep-eql: "npm:^4.1.3" + get-func-name: "npm:^2.0.2" + loupe: "npm:^2.3.6" + pathval: "npm:^1.1.1" + type-detect: "npm:^4.1.0" + checksum: 10c0/b8cb596bd1aece1aec659e41a6e479290c7d9bee5b3ad63d2898ad230064e5b47889a3bc367b20100a0853b62e026e2dc514acf25a3c9385f936aa3614d4ab4d + languageName: node + linkType: hard + "chai@npm:^5.1.2, chai@npm:^5.2.0": version: 5.2.0 resolution: "chai@npm:5.2.0" @@ -3706,6 +3846,15 @@ __metadata: languageName: unknown linkType: soft +"check-error@npm:^1.0.3": + version: 1.0.3 + resolution: "check-error@npm:1.0.3" + dependencies: + get-func-name: "npm:^2.0.2" + checksum: 10c0/94aa37a7315c0e8a83d0112b5bfb5a8624f7f0f81057c73e4707729cdd8077166c6aefb3d8e2b92c63ee130d4a2ff94bad46d547e12f3238cc1d78342a973841 + languageName: node + linkType: hard + "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -4144,6 +4293,15 @@ __metadata: languageName: node linkType: hard +"deep-eql@npm:^4.1.3": + version: 4.1.4 + resolution: "deep-eql@npm:4.1.4" + dependencies: + type-detect: "npm:^4.0.0" + checksum: 10c0/264e0613493b43552fc908f4ff87b8b445c0e6e075656649600e1b8a17a57ee03e960156fce7177646e4d2ddaf8e5ee616d76bd79929ff593e5c79e4e5e6c517 + languageName: node + linkType: hard + "deep-eql@npm:^5.0.1": version: 5.0.2 resolution: "deep-eql@npm:5.0.2" @@ -4217,6 +4375,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10c0/32e27ac7dbffdf2fb0eb5a84efd98a9ad084fbabd5ac9abb8757c6770d5320d2acd172830b28c4add29bb873d59420601dfc805ac4064330ce59b1adfd0593b2 + languageName: node + linkType: hard + "docker-compose@npm:^0.24.8": version: 0.24.8 resolution: "docker-compose@npm:0.24.8" @@ -4806,6 +4971,23 @@ __metadata: languageName: node linkType: hard +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: 10c0/2c52d8775f5bf103ce8eec9c7ab3059909ba350a5164744e9947ed14a53f51687c040a250bda833f906d1283aa8803975b84e6c8f7a7c42f99dc8ef80250d1af + languageName: node + linkType: hard + "execa@npm:^9.5.2": version: 9.5.2 resolution: "execa@npm:9.5.2" @@ -5000,6 +5182,13 @@ __metadata: languageName: node linkType: hard +"get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": + version: 2.0.2 + resolution: "get-func-name@npm:2.0.2" + checksum: 10c0/89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df + languageName: node + linkType: hard + "get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" @@ -5018,7 +5207,7 @@ __metadata: languageName: node linkType: hard -"get-port@npm:^7.1.0": +"get-port@npm:^7.0.0, get-port@npm:^7.1.0": version: 7.1.0 resolution: "get-port@npm:7.1.0" checksum: 10c0/896051fea0fd3df58c050566754ab91f46406e898ce0c708414739d908a5ac03ffef3eca7a494ea9cc1914439e8caccd2218010d1eeabdde914b9ff920fa28fc @@ -5045,6 +5234,13 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 10c0/5c2181e98202b9dae0bb4a849979291043e5892eb40312b47f0c22b9414fc9b28a3b6063d2375705eb24abc41ecf97894d9a51f64ff021511b504477b27b4290 + languageName: node + linkType: hard + "get-stream@npm:^9.0.0": version: 9.0.1 resolution: "get-stream@npm:9.0.1" @@ -5215,6 +5411,13 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 10c0/5a9359073fe17a8b58e5a085e9a39a950366d9f00217c4ff5878bd312e09d80f460536ea6a3f260b5943a01fe55c158d1cea3fc7bee3d0520aeef04f6d915c82 + languageName: node + linkType: hard + "human-signals@npm:^8.0.0": version: 8.0.1 resolution: "human-signals@npm:8.0.1" @@ -5507,6 +5710,13 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 10c0/eb2f7127af02ee9aa2a0237b730e47ac2de0d4e76a4a905a50a11557f2339df5765eaea4ceb8029f1efa978586abe776908720bfcb1900c20c6ec5145f6f29d8 + languageName: node + linkType: hard + "is-stream@npm:^4.0.1": version: 4.0.1 resolution: "is-stream@npm:4.0.1" @@ -5585,6 +5795,13 @@ __metadata: languageName: node linkType: hard +"js-tokens@npm:^9.0.1": + version: 9.0.1 + resolution: "js-tokens@npm:9.0.1" + checksum: 10c0/68dcab8f233dde211a6b5fd98079783cbcd04b53617c1250e3553ee16ab3e6134f5e65478e41d82f6d351a052a63d71024553933808570f04dbf828d7921e80e + languageName: node + linkType: hard + "jsbn@npm:1.1.0": version: 1.1.0 resolution: "jsbn@npm:1.1.0" @@ -5758,6 +5975,16 @@ __metadata: languageName: node linkType: hard +"local-pkg@npm:^0.5.0": + version: 0.5.1 + resolution: "local-pkg@npm:0.5.1" + dependencies: + mlly: "npm:^1.7.3" + pkg-types: "npm:^1.2.1" + checksum: 10c0/ade8346f1dc04875921461adee3c40774b00d4b74095261222ebd4d5fd0a444676e36e325f76760f21af6a60bc82480e154909b54d2d9f7173671e36dacf1808 + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -5820,6 +6047,15 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^2.3.6, loupe@npm:^2.3.7": + version: 2.3.7 + resolution: "loupe@npm:2.3.7" + dependencies: + get-func-name: "npm:^2.0.1" + checksum: 10c0/71a781c8fc21527b99ed1062043f1f2bb30bdaf54fa4cf92463427e1718bc6567af2988300bc243c1f276e4f0876f29e3cbf7b58106fdc186915687456ce5bf4 + languageName: node + linkType: hard + "loupe@npm:^3.1.0, loupe@npm:^3.1.2, loupe@npm:^3.1.3": version: 3.1.3 resolution: "loupe@npm:3.1.3" @@ -5870,7 +6106,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.12, magic-string@npm:^0.30.17": +"magic-string@npm:^0.30.12, magic-string@npm:^0.30.17, magic-string@npm:^0.30.5": version: 0.30.17 resolution: "magic-string@npm:0.30.17" dependencies: @@ -5905,6 +6141,13 @@ __metadata: languageName: node linkType: hard +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + "micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" @@ -5947,6 +6190,13 @@ __metadata: languageName: node linkType: hard +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 10c0/de9cc32be9996fd941e512248338e43407f63f6d497abe8441fa33447d922e927de54d4cc3c1a3c6d652857acd770389d5a3823f311a744132760ce2be15ccbf + languageName: node + linkType: hard + "miniflare@npm:3.20250204.1": version: 3.20250204.1 resolution: "miniflare@npm:3.20250204.1" @@ -6124,7 +6374,7 @@ __metadata: languageName: node linkType: hard -"mlly@npm:^1.7.4": +"mlly@npm:^1.7.3, mlly@npm:^1.7.4": version: 1.7.4 resolution: "mlly@npm:1.7.4" dependencies: @@ -6276,6 +6526,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: "npm:^4.0.0" + checksum: 10c0/124df74820c40c2eb9a8612a254ea1d557ddfab1581c3e751f825e3e366d9f00b0d76a3c94ecd8398e7f3eee193018622677e95816e8491f0797b21e30b2deba + languageName: node + linkType: hard + "npm-run-path@npm:^6.0.0": version: 6.0.0 resolution: "npm-run-path@npm:6.0.0" @@ -6346,6 +6605,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: "npm:^4.0.0" + checksum: 10c0/4eef7c6abfef697dd4479345a4100c382d73c149d2d56170a54a07418c50816937ad09500e1ed1e79d235989d073a9bade8557122aee24f0576ecde0f392bb6c + languageName: node + linkType: hard + "open@npm:^10.1.0": version: 10.1.0 resolution: "open@npm:10.1.0" @@ -6367,6 +6635,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^5.0.0": + version: 5.0.0 + resolution: "p-limit@npm:5.0.0" + dependencies: + yocto-queue: "npm:^1.0.0" + checksum: 10c0/574e93b8895a26e8485eb1df7c4b58a1a6e8d8ae41b1750cc2cc440922b3d306044fc6e9a7f74578a883d46802d9db72b30f2e612690fcef838c173261b1ed83 + languageName: node + linkType: hard + "p-locate@npm:^5.0.0": version: 5.0.0 resolution: "p-locate@npm:5.0.0" @@ -6460,7 +6737,7 @@ __metadata: languageName: node linkType: hard -"pathe@npm:^1.1.2": +"pathe@npm:^1.1.1, pathe@npm:^1.1.2": version: 1.1.2 resolution: "pathe@npm:1.1.2" checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 @@ -6474,6 +6751,13 @@ __metadata: languageName: node linkType: hard +"pathval@npm:^1.1.1": + version: 1.1.1 + resolution: "pathval@npm:1.1.1" + checksum: 10c0/f63e1bc1b33593cdf094ed6ff5c49c1c0dc5dc20a646ca9725cc7fe7cd9995002d51d5685b9b2ec6814342935748b711bafa840f84c0bb04e38ff40a335c94dc + languageName: node + linkType: hard + "pathval@npm:^2.0.0": version: 2.0.0 resolution: "pathval@npm:2.0.0" @@ -6558,7 +6842,7 @@ __metadata: languageName: node linkType: hard -"pkg-types@npm:^1.3.0": +"pkg-types@npm:^1.2.1, pkg-types@npm:^1.3.0": version: 1.3.1 resolution: "pkg-types@npm:1.3.1" dependencies: @@ -6681,6 +6965,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10c0/edc5ff89f51916f036c62ed433506b55446ff739358de77207e63e88a28ca2894caac6e73dcb68166a606e51c8087d32d400473e6a9fdd2dbe743f46c9c0276f + languageName: node + linkType: hard + "pretty-ms@npm:^9.0.0": version: 9.2.0 resolution: "pretty-ms@npm:9.2.0" @@ -6836,6 +7131,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 + languageName: node + linkType: hard + "react-reconciler@npm:^0.29.0": version: 0.29.2 resolution: "react-reconciler@npm:0.29.2" @@ -7526,7 +7828,7 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.8.0, std-env@npm:^3.8.1": +"std-env@npm:^3.5.0, std-env@npm:^3.8.0, std-env@npm:^3.8.1": version: 3.9.0 resolution: "std-env@npm:3.9.0" checksum: 10c0/4a6f9218aef3f41046c3c7ecf1f98df00b30a07f4f35c6d47b28329bc2531eef820828951c7d7b39a1c5eb19ad8a46e3ddfc7deb28f0a2f3ceebee11bab7ba50 @@ -7623,6 +7925,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 10c0/a771a17901427bac6293fd416db7577e2bc1c34a19d38351e9d5478c3c415f523f391003b42ed475f27e33a78233035df183525395f731d3bfb8cdcbd4da08ce + languageName: node + linkType: hard + "strip-final-newline@npm:^4.0.0": version: 4.0.0 resolution: "strip-final-newline@npm:4.0.0" @@ -7630,6 +7939,15 @@ __metadata: languageName: node linkType: hard +"strip-literal@npm:^2.0.0": + version: 2.1.1 + resolution: "strip-literal@npm:2.1.1" + dependencies: + js-tokens: "npm:^9.0.1" + checksum: 10c0/66a7353f5ba1ae6a4fb2805b4aba228171847200640083117c41512692e6b2c020e18580402984f55c0ae69c30f857f9a55abd672863e4ca8fdb463fdf93ba19 + languageName: node + linkType: hard + "sucrase@npm:^3.35.0": version: 3.35.0 resolution: "sucrase@npm:3.35.0" @@ -7801,7 +8119,7 @@ __metadata: languageName: node linkType: hard -"tinybench@npm:^2.9.0": +"tinybench@npm:^2.5.1, tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0" checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c @@ -7842,6 +8160,13 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^0.8.3": + version: 0.8.4 + resolution: "tinypool@npm:0.8.4" + checksum: 10c0/779c790adcb0316a45359652f4b025958c1dff5a82460fe49f553c864309b12ad732c8288be52f852973bc76317f5e7b3598878aee0beb8a33322c0e72c4a66c + languageName: node + linkType: hard + "tinypool@npm:^1.0.1, tinypool@npm:^1.0.2": version: 1.0.2 resolution: "tinypool@npm:1.0.2" @@ -7863,6 +8188,13 @@ __metadata: languageName: node linkType: hard +"tinyspy@npm:^2.2.0": + version: 2.2.1 + resolution: "tinyspy@npm:2.2.1" + checksum: 10c0/0b4cfd07c09871e12c592dfa7b91528124dc49a4766a0b23350638c62e6a483d5a2a667de7e6282246c0d4f09996482ddaacbd01f0c05b7ed7e0f79d32409bdc + languageName: node + linkType: hard + "tinyspy@npm:^3.0.2": version: 3.0.2 resolution: "tinyspy@npm:3.0.2" @@ -8061,6 +8393,13 @@ __metadata: languageName: node linkType: hard +"type-detect@npm:^4.0.0, type-detect@npm:^4.1.0": + version: 4.1.0 + resolution: "type-detect@npm:4.1.0" + checksum: 10c0/df8157ca3f5d311edc22885abc134e18ff8ffbc93d6a9848af5b682730ca6a5a44499259750197250479c5331a8a75b5537529df5ec410622041650a7f293e2a + languageName: node + linkType: hard + "type-fest@npm:^1.0.2": version: 1.4.0 resolution: "type-fest@npm:1.4.0" @@ -8268,6 +8607,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:1.6.1": + version: 1.6.1 + resolution: "vite-node@npm:1.6.1" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.4" + pathe: "npm:^1.1.1" + picocolors: "npm:^1.0.0" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/4d96da9f11bd0df8b60c46e65a740edaad7dd2d1aff3cdb3da5714ea8c10b5f2683111b60bfe45545c7e8c1f33e7e8a5095573d5e9ba55f50a845233292c2e02 + languageName: node + linkType: hard + "vite-node@npm:2.1.9": version: 2.1.9 resolution: "vite-node@npm:2.1.9" @@ -8393,7 +8747,57 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^3.0.8, vitest@npm:^3.0.9": +"vitest@npm:^1.4.0, vitest@npm:^1.6.0": + version: 1.6.1 + resolution: "vitest@npm:1.6.1" + dependencies: + "@vitest/expect": "npm:1.6.1" + "@vitest/runner": "npm:1.6.1" + "@vitest/snapshot": "npm:1.6.1" + "@vitest/spy": "npm:1.6.1" + "@vitest/utils": "npm:1.6.1" + acorn-walk: "npm:^8.3.2" + chai: "npm:^4.3.10" + debug: "npm:^4.3.4" + execa: "npm:^8.0.1" + local-pkg: "npm:^0.5.0" + magic-string: "npm:^0.30.5" + pathe: "npm:^1.1.1" + picocolors: "npm:^1.0.0" + std-env: "npm:^3.5.0" + strip-literal: "npm:^2.0.0" + tinybench: "npm:^2.5.1" + tinypool: "npm:^0.8.3" + vite: "npm:^5.0.0" + vite-node: "npm:1.6.1" + why-is-node-running: "npm:^2.2.2" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 1.6.1 + "@vitest/ui": 1.6.1 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/511d27d7f697683964826db2fad7ac303f9bc7eeb59d9422111dc488371ccf1f9eed47ac3a80eb47ca86b7242228ba5ca9cc3613290830d0e916973768cac215 + languageName: node + linkType: hard + +"vitest@npm:^3.0.8, vitest@npm:^3.0.9, vitest@npm:^3.1.1": version: 3.1.1 resolution: "vitest@npm:3.1.1" dependencies: @@ -8574,7 +8978,7 @@ __metadata: languageName: node linkType: hard -"why-is-node-running@npm:^2.3.0": +"why-is-node-running@npm:^2.2.2, why-is-node-running@npm:^2.3.0": version: 2.3.0 resolution: "why-is-node-running@npm:2.3.0" dependencies: @@ -8855,6 +9259,13 @@ __metadata: languageName: node linkType: hard +"yocto-queue@npm:^1.0.0": + version: 1.2.1 + resolution: "yocto-queue@npm:1.2.1" + checksum: 10c0/5762caa3d0b421f4bdb7a1926b2ae2189fc6e4a14469258f183600028eb16db3e9e0306f46e8ebf5a52ff4b81a881f22637afefbef5399d6ad440824e9b27f9f + languageName: node + linkType: hard + "yoctocolors@npm:^2.0.0": version: 2.1.1 resolution: "yoctocolors@npm:2.1.1"