From 806d6f8e07b1fd9aec08558ba07d98987c507be5 Mon Sep 17 00:00:00 2001 From: talge-a11y Date: Mon, 14 Sep 2026 14:22:38 +0300 Subject: [PATCH] introduce actors cli support --- docs/commands.md | 8 + docs/plugins.md | 6 +- docs/resources.md | 40 +- docs/testing.md | 7 + .../cli/src/cli/commands/actors/delete.ts | 70 +++ .../cli/src/cli/commands/actors/deploy.ts | 82 ++++ packages/cli/src/cli/commands/actors/index.ts | 10 + .../cli/src/cli/commands/project/deploy.ts | 33 +- .../cli/src/cli/commands/types/generate.ts | 3 +- packages/cli/src/cli/dev/dev-server/main.ts | 6 + packages/cli/src/cli/program.ts | 2 + packages/cli/src/core/errors.ts | 4 + packages/cli/src/core/project/config.ts | 60 ++- packages/cli/src/core/project/deploy.ts | 46 +- packages/cli/src/core/project/schema.ts | 1 + packages/cli/src/core/project/types.ts | 2 + packages/cli/src/core/resources/actor/api.ts | 61 +++ .../cli/src/core/resources/actor/config.ts | 62 +++ .../cli/src/core/resources/actor/deploy.ts | 76 +++ .../cli/src/core/resources/actor/index.ts | 5 + .../cli/src/core/resources/actor/resource.ts | 9 + .../cli/src/core/resources/actor/schema.ts | 81 ++++ packages/cli/src/core/resources/index.ts | 1 + packages/cli/src/core/types/generator.ts | 13 +- packages/cli/tests/cli/actors.spec.ts | 445 ++++++++++++++++++ .../cli/tests/cli/actors_deploy_all.spec.ts | 109 +++++ packages/cli/tests/cli/branch_scope.spec.ts | 5 + packages/cli/tests/cli/dev-actors.spec.ts | 34 ++ packages/cli/tests/cli/types_generate.spec.ts | 2 +- packages/cli/tests/core/actor-config.spec.ts | 80 ++++ packages/cli/tests/core/actor-types.spec.ts | 69 +++ .../actor-validation/duplicate/Room/entry.js | 1 + .../actor-validation/duplicate/Room/entry.ts | 1 + .../invalid/bad-name/entry.ts | 1 + .../nested/Outer/Inner/entry.ts | 1 + .../actor-validation/reserved/class/entry.ts | 1 + .../fixtures/actor-validation/root/entry.ts | 1 + .../fixtures/with-actors/base44/.app.jsonc | 1 + .../base44/.types/actor-messages.d.ts | 7 + .../base44/actors/.scratch/entry.ts | 1 + .../base44/actors/ChatRoom/README.md | 1 + .../base44/actors/ChatRoom/data.json | 1 + .../base44/actors/ChatRoom/deno.jsonc | 1 + .../base44/actors/ChatRoom/entry.ts | 9 + .../base44/actors/ChatRoom/lib/message.ts | 1 + .../base44/actors/Counter/entry.js | 7 + .../with-actors/base44/actors/shared.ts | 1 + .../fixtures/with-actors/base44/config.jsonc | 1 + .../with-actors/check-actor-client.ts | 14 + .../tests/fixtures/with-actors/check-types.ts | 7 + 50 files changed, 1460 insertions(+), 30 deletions(-) create mode 100644 packages/cli/src/cli/commands/actors/delete.ts create mode 100644 packages/cli/src/cli/commands/actors/deploy.ts create mode 100644 packages/cli/src/cli/commands/actors/index.ts create mode 100644 packages/cli/src/core/resources/actor/api.ts create mode 100644 packages/cli/src/core/resources/actor/config.ts create mode 100644 packages/cli/src/core/resources/actor/deploy.ts create mode 100644 packages/cli/src/core/resources/actor/index.ts create mode 100644 packages/cli/src/core/resources/actor/resource.ts create mode 100644 packages/cli/src/core/resources/actor/schema.ts create mode 100644 packages/cli/tests/cli/actors.spec.ts create mode 100644 packages/cli/tests/cli/actors_deploy_all.spec.ts create mode 100644 packages/cli/tests/cli/dev-actors.spec.ts create mode 100644 packages/cli/tests/core/actor-config.spec.ts create mode 100644 packages/cli/tests/core/actor-types.spec.ts create mode 100644 packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.js create mode 100644 packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-validation/invalid/bad-name/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-validation/nested/Outer/Inner/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-validation/reserved/class/entry.ts create mode 100644 packages/cli/tests/fixtures/actor-validation/root/entry.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/.app.jsonc create mode 100644 packages/cli/tests/fixtures/with-actors/base44/.types/actor-messages.d.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/.scratch/entry.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/README.md create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/data.json create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/deno.jsonc create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/lib/message.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/Counter/entry.js create mode 100644 packages/cli/tests/fixtures/with-actors/base44/actors/shared.ts create mode 100644 packages/cli/tests/fixtures/with-actors/base44/config.jsonc create mode 100644 packages/cli/tests/fixtures/with-actors/check-actor-client.ts create mode 100644 packages/cli/tests/fixtures/with-actors/check-types.ts diff --git a/docs/commands.md b/docs/commands.md index ddfce2723..5247aacbd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -4,6 +4,14 @@ Commands live in `src/cli/commands//`. They use a **factory pattern** — each file exports a function that returns a `Base44Command`. +## Actor command wiring + +`src/cli/commands/actors/` registers deploy and delete through the same lifecycle as +functions. Keep name validation inside the action so failures use the global +JSON error envelope. Successful results use `RunCommandResult.stdout`; progress +and server warnings use the context logger. Deployment reads project resources; +deletion only needs the selected app context. + ## Branch targeting Discover names with `base44 branches list --app-id --json`. diff --git a/docs/plugins.md b/docs/plugins.md index dc67ac10b..f1597acbb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -55,6 +55,10 @@ The namespace must be unique in the host project. It may contain letters, number 7. Append plugin functions after renaming them to `__`. 8. Validate the final entity/function names before returning `ProjectData`. +Actor discovery is skipped for imported plugins. Host actor names are checked +against both local functions and the final namespaced plugin functions before any +deployment starts. + Project-owned resources use `source: { type: "project" }`. ## Entities @@ -111,7 +115,7 @@ Rules: ## Rules - Plugins may contribute only entities and backend functions. -- Plugin agents, connectors, and auth config are ignored. +- Plugin actors, agents, connectors, and auth config are ignored. - A project that declares `plugin` cannot also define `plugins`. - Plugin entity names are global within the host project; they are not namespaced. - Entity extensions cannot add or merge top-level RLS rules yet. diff --git a/docs/resources.md b/docs/resources.md index 29013452b..91a83a010 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -1,8 +1,8 @@ # Working with Resources -**Keywords:** resource, entity, function, agent, agent skill, connector, push, readAll, deploy, site, tar.gz, deployAll, ProjectData +**Keywords:** resource, entity, function, actor, agent, agent skill, connector, push, readAll, deploy, site, tar.gz, deployAll, ProjectData -Resources are project-specific collections (entities, functions, agents, agent skills, connectors) that can be read from the filesystem and pushed to the Base44 API. +Resources are project-specific collections (entities, functions, actors, agents, agent skills, connectors) that can be read from the filesystem and pushed to the Base44 API. ## Resource Interface @@ -85,6 +85,22 @@ Deploy ships file contents verbatim — the source is never parsed or linted — Entry files may also import `secrets` and `waitUntil` from `base44:runtime`. Locally, `base44 dev` runs functions on workerd via Miniflare by default — each function is bundled with esbuild + `@deno/loader` (`src/cli/dev/dev-server/function-bundler.ts`), with `base44:runtime` served as a virtual module, secrets as real Worker env bindings and `waitUntil` riding `ctx.waitUntil`. A fallback runtime covers installations where workerd is unavailable (compiled binaries, `B44_DEV_FUNCTIONS_RUNTIME=deno`) and supplies `base44:runtime` via an import map. A project-level `deno.json` import map is not applied to functions — locally or deployed — since only files under `base44/` are uploaded. See [`packages/cli/backend-runtime/README.md`](../packages/cli/backend-runtime/README.md) for the local implementation and its intentional differences from production. +## Actor integration + +The resource implementation is in `src/core/resources/actor/`. The upload adapter +reads discovered files with the existing file utilities and strips local paths +and source metadata. Actor payloads never use function shared-file assembly. +The platform validates the source and handles publication. + +`ProjectConfigReader` loads project actors, skips actor discovery for imported +plugins, and checks actor names against the final merged function names. +`src/core/types/generator.ts` augments the SDK with a top-level import so registry-only +output preserves SDK exports. Message declarations remain user-owned. + +The dev server registers its actor handler before the production proxy. Keep +that registration unconditional; it also covers requests for actors absent from +the local checkout. + ## Agent skills Agent skills are app-scoped instruction snippets shared across the app's agents. Unlike other resources they are stored as one markdown file per skill under the agent-skills directory (`base44/agent-skills/`, or `agentSkillsDir` in `config.jsonc`): the filename (without `.md`) is the skill name, the frontmatter `description` is the summary, and the body is the instruction text. Agents reference skills by name via `selected_skill_names`; `selected_workspace_skill_ids` (org-shared workspace skills) is not managed here and is passed through pull/push/deploy untouched. @@ -137,12 +153,20 @@ const { appUrl } = await deployAll(projectData); ``` What it deploys (in order): -1. Entities (via `entityResource.push()`) -2. Functions (via `functionResource.push()`) -3. Agent skills (via `agentSkillResource.push()`) -4. Agents (via `agentResource.push()`) -5. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs -6. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The deployments-API transport is not reachable from here; see [deployments.md](deployments.md). +1. App visibility, when configured +2. Entities (via `entityResource.push()`) +3. Functions (via `deployFunctionsSequentially()`) +4. Actors (via `deployActorsSequentially()`) +5. Agent skills (via `agentSkillResource.push()`) +6. Agents (via `agentResource.push()`) +7. Auth config (via `authConfigResource.push()`) +8. Connectors (via `pushConnectors()`) -- may return OAuth redirect URLs +9. Site (if `site.outputDirectory` is configured) — the legacy tar.gz upload. The deployments-API transport is not reachable from here; see [deployments.md](deployments.md). + +Function and actor batches collect each item's result. `deployAll()` must inspect +those results: any failure raises `ResourceDeploymentError` before the next stage. +Its details retain completed work and per-item outcomes for human and JSON error +output. Earlier successful deployments are not rolled back. ```bash base44 deploy # With confirmation prompt diff --git a/docs/testing.md b/docs/testing.md index 0cebb4793..ed3e18995 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -372,6 +372,13 @@ function getTestOverride(): MyType | undefined { } ``` +## Actor Tests + +Actor coverage lives in `core/actor-config.spec.ts`, `core/actor-types.spec.ts`, +`cli/actors.spec.ts`, `cli/actors_deploy_all.spec.ts`, and `cli/dev-actors.spec.ts`. +The command tests capture requests with `mockRoute` to verify payload boundaries, +partial outcomes, and stage ordering. + ## Testing Rules 1. **Build first** -- Always `bun run build` before testing; add `bun run build:binaries` for binary mode diff --git a/packages/cli/src/cli/commands/actors/delete.ts b/packages/cli/src/cli/commands/actors/delete.ts new file mode 100644 index 000000000..8f5b97989 --- /dev/null +++ b/packages/cli/src/cli/commands/actors/delete.ts @@ -0,0 +1,70 @@ +import type { Command } from "commander"; +import { parseNames } from "@/cli/commands/functions/parseNames.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { + ApiError, + InvalidInputError, + ResourceDeploymentError, +} from "@/core/errors.js"; +import { + actorOperationError, + deleteSingleActor, + describeActorResult, + type SingleActorDeleteResult, + validateActorName, +} from "@/core/resources/actor/index.js"; + +async function deleteActorsAction( + { log, jsonMode, runTask }: CLIContext, + rawNames: string[], +): Promise { + const names = [...new Set(parseNames(rawNames))]; + if (!names.length) + throw new InvalidInputError("At least one actor name is required"); + names.forEach(validateActorName); + const results: SingleActorDeleteResult[] = []; + for (const name of names) { + try { + await runTask(`Deleting ${name}...`, () => deleteSingleActor(name), { + successMessage: `${name} deleted`, + errorMessage: `Failed to delete ${name}`, + }); + results.push({ name, status: "deleted" }); + } catch (error) { + if (error instanceof ApiError && error.statusCode === 404) { + results.push({ name, status: "not_found" }); + log.info(`${name} not found`); + } else { + const result = actorOperationError(name, error); + results.push(result); + log.error(describeActorResult(result)); + } + } + } + const summary = { + deleted: results.filter((result) => result.status === "deleted").length, + notFound: results.filter((result) => result.status === "not_found").length, + failed: results.filter((result) => result.status === "error").length, + }; + if (summary.failed) + throw new ResourceDeploymentError("Actor deletion failed", { + details: results.map(describeActorResult), + }); + return { + outroMessage: + names.length === 1 + ? `Actor "${names[0]}" ${summary.deleted ? "deleted" : "not found"}` + : `${summary.deleted} deleted, ${summary.notFound} not found`, + stdout: jsonMode + ? `${JSON.stringify({ actors: results, summary })}\n` + : undefined, + }; +} + +export function getDeleteCommand(): Command { + return new Base44Command("delete") + .description("Delete deployed actors") + .argument("[names...]", "Actor names to delete (required)") + .action(deleteActorsAction); +} diff --git a/packages/cli/src/cli/commands/actors/deploy.ts b/packages/cli/src/cli/commands/actors/deploy.ts new file mode 100644 index 000000000..d3c45df2b --- /dev/null +++ b/packages/cli/src/cli/commands/actors/deploy.ts @@ -0,0 +1,82 @@ +import type { Command } from "commander"; +import { formatDeployResult } from "@/cli/commands/functions/formatDeployResult.js"; +import { parseNames } from "@/cli/commands/functions/parseNames.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { InvalidInputError, ResourceDeploymentError } from "@/core/errors.js"; +import { readProjectConfig } from "@/core/project/config.js"; +import { + deployActorsSequentially, + describeActorResult, + validateActorName, +} from "@/core/resources/actor/index.js"; + +async function deployActorsAction( + { log, jsonMode }: CLIContext, + rawNames: string[], +): Promise { + const names = [...new Set(parseNames(rawNames))]; + if (rawNames.length && !names.length) + throw new InvalidInputError("At least one actor name is required"); + names.forEach(validateActorName); + const { actors, project } = await readProjectConfig(); + const notFound = names.filter( + (name) => !actors.some((actor) => actor.name === name), + ); + if (notFound.length) + throw new InvalidInputError( + `Actor not found in project: ${notFound.join(", ")}`, + ); + const selected = names.length + ? actors.filter((actor) => names.includes(actor.name)) + : actors; + let completed = 0; + if (selected.length) + log.info( + `Found ${selected.length} ${selected.length === 1 ? "actor" : "actors"} to deploy`, + ); + const results = await deployActorsSequentially(selected, { + onStart: (name) => + log.step( + theme.styles.dim( + `[${completed + 1}/${selected.length}] Deploying ${name}...`, + ), + ), + onResult: (result) => { + completed++; + formatDeployResult(result, log); + if (result.status !== "error") + for (const warning of result.warnings) + log.warn(`${result.name}: ${warning}`); + }, + }); + const summary = { + deployed: results.filter((result) => result.status === "deployed").length, + unchanged: results.filter((result) => result.status === "unchanged").length, + failed: results.filter((result) => result.status === "error").length, + }; + const message = Object.entries(summary) + .filter(([, count]) => count > 0) + .map(([status, count]) => `${count} ${status}`) + .join(", "); + if (summary.failed) { + throw new ResourceDeploymentError(message, { + details: results.map(describeActorResult), + }); + } + return { + outroMessage: + message || + `No actors found. Create actors in the '${project.actorsDir}' directory.`, + stdout: jsonMode + ? `${JSON.stringify({ actors: results, summary })}\n` + : undefined, + }; +} + +export function getDeployCommand(): Command { + return new Base44Command("deploy") + .description("Deploy actors to Base44") + .argument("[names...]", "Actor names to deploy (deploys all if omitted)") + .action(deployActorsAction); +} diff --git a/packages/cli/src/cli/commands/actors/index.ts b/packages/cli/src/cli/commands/actors/index.ts new file mode 100644 index 000000000..c35187d32 --- /dev/null +++ b/packages/cli/src/cli/commands/actors/index.ts @@ -0,0 +1,10 @@ +import { Command } from "commander"; +import { getDeleteCommand } from "./delete.js"; +import { getDeployCommand } from "./deploy.js"; + +export function getActorsCommand(): Command { + return new Command("actors") + .description("Manage realtime actors") + .addCommand(getDeployCommand()) + .addCommand(getDeleteCommand()); +} diff --git a/packages/cli/src/cli/commands/project/deploy.ts b/packages/cli/src/cli/commands/project/deploy.ts index 986c01ffc..624abeebc 100644 --- a/packages/cli/src/cli/commands/project/deploy.ts +++ b/packages/cli/src/cli/commands/project/deploy.ts @@ -48,8 +48,15 @@ export async function deployAction( }; } - const { project, entities, functions, agents, connectors, authConfig } = - projectData; + const { + project, + entities, + functions, + actors, + agents, + connectors, + authConfig, + } = projectData; // Build summary of what will be deployed const summaryLines: string[] = []; @@ -63,6 +70,11 @@ export async function deployAction( ` - ${functions.length} ${functions.length === 1 ? "function" : "functions"}`, ); } + if (actors.length > 0) { + summaryLines.push( + ` - ${actors.length} ${actors.length === 1 ? "actor" : "actors"}`, + ); + } if (agents.length > 0) { summaryLines.push( ` - ${agents.length} ${agents.length === 1 ? "agent" : "agents"}`, @@ -105,6 +117,7 @@ export async function deployAction( // Deploy resources with per-function progress let functionCompleted = 0; const functionTotal = functions.length; + let actorCompleted = 0; const result = await deployAll(projectData, { onVisibilitySet: (level) => { @@ -122,6 +135,20 @@ export async function deployAction( functionCompleted++; formatDeployResult(r, log); }, + onActorStart: (name) => { + log.step( + theme.styles.dim( + `[${actorCompleted + 1}/${actors.length}] Deploying ${name}...`, + ), + ); + }, + onActorResult: (result) => { + actorCompleted++; + formatDeployResult(result, log); + if (result.status !== "error") + for (const warning of result.warnings) + log.warn(`${result.name}: ${warning}`); + }, }); // Handle connector-specific post-deploy flows @@ -147,7 +174,7 @@ export async function deployAction( export function getDeployCommand(): Command { return new Base44Command("deploy") .description( - "Deploy all project resources (entities, functions, agents, connectors, and site)", + "Deploy all project resources (entities, functions, actors, agents, connectors, and site)", ) .option("-y, --yes", "Skip confirmation prompt") .option("--build", "Build the site before deploying (skips the prompt)") diff --git a/packages/cli/src/cli/commands/types/generate.ts b/packages/cli/src/cli/commands/types/generate.ts index f74545af1..8fa82c0d0 100644 --- a/packages/cli/src/cli/commands/types/generate.ts +++ b/packages/cli/src/cli/commands/types/generate.ts @@ -9,7 +9,7 @@ const TYPES_FILE_PATH = "base44/.types/types.d.ts"; async function generateTypesAction({ runTask, }: CLIContext): Promise { - const { entities, functions, agents, connectors, project } = + const { entities, functions, actors, agents, connectors, project } = await readProjectConfig(); await runTask("Generating types", async () => { @@ -17,6 +17,7 @@ async function generateTypesAction({ projectRoot: project.root, entities, functions, + actors, agents, connectors, }); diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index f1ffc438b..d067f97e8 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -90,6 +90,12 @@ export async function createDevServer( const devLogger = createDevLogger("backend", theme.styles.info); + app.use("/api/apps/:appId/actors", (_req, res) => { + const message = "Actors are not available in local development"; + devLogger.error(message); + res.status(500).json({ error: message }); + }); + const functionManager = await createFunctionRuntime( functions, devLogger, diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 86ac1ed3d..070dfc749 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -1,4 +1,5 @@ import { Command, Option } from "commander"; +import { getActorsCommand } from "@/cli/commands/actors/index.js"; import { getAgentSkillsCommand } from "@/cli/commands/agent-skills/index.js"; import { getAgentsCommand } from "@/cli/commands/agents/index.js"; import { getAuthCommand } from "@/cli/commands/auth/index.js"; @@ -99,6 +100,7 @@ export function createProgram(context: CLIContext): Command { // Register functions commands program.addCommand(getFunctionsCommand()); + program.addCommand(getActorsCommand()); // Register workflows commands program.addCommand(getWorkflowsCommand()); diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index b5bf8049a..a93d1bf97 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -535,6 +535,10 @@ export class InternalError extends SystemError { } } +export class ResourceDeploymentError extends SystemError { + readonly code = "RESOURCE_DEPLOYMENT_FAILED"; +} + /** * Thrown when type generation fails for an entity. */ diff --git a/packages/cli/src/core/project/config.ts b/packages/cli/src/core/project/config.ts index 4f32856c6..419cd1491 100644 --- a/packages/cli/src/core/project/config.ts +++ b/packages/cli/src/core/project/config.ts @@ -22,6 +22,7 @@ import type { ProjectRoot, ProjectWithPaths, } from "@/core/project/types.js"; +import { actorResource } from "@/core/resources/actor/index.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -67,11 +68,21 @@ class ProjectConfigReader { ...pluginResources.functions, ]; this.validateFunctionNames(functions, configPath); + const functionNames = new Set(functions.map((fn) => fn.name)); + for (const actor of localResources.actors) { + if (functionNames.has(actor.name)) { + throw new ConfigInvalidError( + `'${actor.name}' exists as both a backend function and an actor`, + configPath, + ); + } + } return { project, entities, functions, + actors: localResources.actors, agents: localResources.agents, agentSkills: localResources.agentSkills, connectors: localResources.connectors, @@ -116,19 +127,38 @@ class ProjectConfigReader { private async readProjectResources( configPath: string, project: ProjectConfig, + includeActors = true, ): Promise { const configDir = dirname(configPath); - const [entities, functions, agents, agentSkills, connectors, authConfig] = - await Promise.all([ - entityResource.readAll(join(configDir, project.entitiesDir)), - functionResource.readAll(join(configDir, project.functionsDir)), - agentResource.readAll(join(configDir, project.agentsDir)), - agentSkillResource.readAll(join(configDir, project.agentSkillsDir)), - connectorResource.readAll(join(configDir, project.connectorsDir)), - authConfigResource.readAll(join(configDir, project.authDir)), - ]); - - return { entities, functions, agents, agentSkills, connectors, authConfig }; + const [ + entities, + functions, + actors, + agents, + agentSkills, + connectors, + authConfig, + ] = await Promise.all([ + entityResource.readAll(join(configDir, project.entitiesDir)), + functionResource.readAll(join(configDir, project.functionsDir)), + includeActors + ? actorResource.readAll(join(configDir, project.actorsDir)) + : Promise.resolve([]), + agentResource.readAll(join(configDir, project.agentsDir)), + agentSkillResource.readAll(join(configDir, project.agentSkillsDir)), + connectorResource.readAll(join(configDir, project.connectorsDir)), + authConfigResource.readAll(join(configDir, project.authDir)), + ]); + + return { + entities, + functions, + actors, + agents, + agentSkills, + connectors, + authConfig, + }; } private assertPluginProjectDoesNotLoadPlugins( @@ -193,11 +223,16 @@ class ProjectConfigReader { configPath: string, namespace: string, ): Promise { - const resources = await this.readProjectResources(configPath, project); + const resources = await this.readProjectResources( + configPath, + project, + false, + ); return { entities: markPluginEntities(resources.entities, namespace), functions: namespacePluginFunctions(resources.functions, namespace), + actors: [], agents: [], agentSkills: [], connectors: [], @@ -255,6 +290,7 @@ class ProjectConfigReader { return { entities, functions, + actors: [], agents: [], agentSkills: [], connectors: [], diff --git a/packages/cli/src/core/project/deploy.ts b/packages/cli/src/core/project/deploy.ts index 99ff0ef95..4a6711976 100644 --- a/packages/cli/src/core/project/deploy.ts +++ b/packages/cli/src/core/project/deploy.ts @@ -1,8 +1,14 @@ import { resolve } from "node:path"; import { hasWorkspaceApiKeyAuth } from "@/core/auth/config.js"; +import { ResourceDeploymentError } from "@/core/errors.js"; import { setAppVisibility } from "@/core/project/api.js"; import type { Visibility } from "@/core/project/schema.js"; import type { ProjectData } from "@/core/project/types.js"; +import { + deployActorsSequentially, + describeActorResult, + type SingleActorDeployResult, +} from "@/core/resources/actor/index.js"; import { agentResource } from "@/core/resources/agent/index.js"; import { agentSkillResource } from "@/core/resources/agent-skill/index.js"; import { authConfigResource } from "@/core/resources/auth-config/index.js"; @@ -28,6 +34,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { project, entities, functions, + actors, agents, agentSkills, connectors, @@ -36,6 +43,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { const hasSite = Boolean(project.site?.outputDirectory); const hasEntities = entities.length > 0; const hasFunctions = functions.length > 0; + const hasActors = actors.length > 0; const hasAgents = agents.length > 0; const hasAgentSkills = agentSkills.length > 0; const hasConnectors = connectors.length > 0; @@ -45,6 +53,7 @@ export function hasResourcesToDeploy(projectData: ProjectData): boolean { return ( hasEntities || hasFunctions || + hasActors || hasAgents || hasAgentSkills || hasConnectors || @@ -69,6 +78,8 @@ interface DeployAllResult { } interface DeployAllOptions { + onActorStart?: (name: string) => void; + onActorResult?: (result: SingleActorDeployResult) => void; onFunctionStart?: (names: string[]) => void; onFunctionResult?: (result: SingleFunctionDeployResult) => void; onVisibilitySet?: (visibility: Visibility) => void; @@ -89,6 +100,7 @@ export async function deployAll( project, entities, functions, + actors, agents, agentSkills, connectors, @@ -100,10 +112,42 @@ export async function deployAll( options?.onVisibilitySet?.(project.visibility); } await entityResource.push(entities); - await deployFunctionsSequentially(functions, { + const functionResults = await deployFunctionsSequentially(functions, { onStart: options?.onFunctionStart, onResult: options?.onFunctionResult, }); + const completedStages = [ + ...(project.visibility ? [`Visibility set to ${project.visibility}`] : []), + ...(entities.length ? [`Entities synced: ${entities.length}`] : []), + ]; + const functionDetails = functionResults.map( + (result) => + `Function ${result.name}: ${result.status}${result.error ? ` — ${result.error}` : ""}`, + ); + if (functionResults.some((result) => result.status === "error")) { + throw new ResourceDeploymentError( + "Function deployment failed; remaining deploy stages were not run", + { + details: [...completedStages, ...functionDetails], + }, + ); + } + const actorResults = await deployActorsSequentially(actors, { + onStart: options?.onActorStart, + onResult: options?.onActorResult, + }); + if (actorResults.some((result) => result.status === "error")) { + throw new ResourceDeploymentError( + "Actor deployment failed; remaining deploy stages were not run", + { + details: [ + ...completedStages, + ...functionDetails, + ...actorResults.map(describeActorResult), + ], + }, + ); + } await agentSkillResource.push(agentSkills); await agentResource.push(agents); await authConfigResource.push(authConfig); diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 6d4412f3b..42041acfb 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -49,6 +49,7 @@ export const ProjectConfigSchema = z.object({ site: SiteConfigSchema.optional(), entitiesDir: z.string().optional().default("entities"), functionsDir: z.string().optional().default("functions"), + actorsDir: z.string().optional().default("actors"), agentsDir: z.string().optional().default("agents"), agentSkillsDir: z.string().optional().default("agent-skills"), connectorsDir: z.string().optional().default("connectors"), diff --git a/packages/cli/src/core/project/types.ts b/packages/cli/src/core/project/types.ts index b69b14682..2d831b018 100644 --- a/packages/cli/src/core/project/types.ts +++ b/packages/cli/src/core/project/types.ts @@ -1,4 +1,5 @@ import type { ProjectConfig } from "@/core/project/schema.js"; +import type { ActorDefinition } from "@/core/resources/actor/index.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { AgentSkill } from "@/core/resources/agent-skill/index.js"; import type { AuthConfig } from "@/core/resources/auth-config/index.js"; @@ -20,6 +21,7 @@ export interface ProjectData { project: ProjectWithPaths; entities: Entity[]; functions: BackendFunction[]; + actors: ActorDefinition[]; agents: AgentConfig[]; agentSkills: AgentSkill[]; connectors: ConnectorResource[]; diff --git a/packages/cli/src/core/resources/actor/api.ts b/packages/cli/src/core/resources/actor/api.ts new file mode 100644 index 000000000..9d253eb11 --- /dev/null +++ b/packages/cli/src/core/resources/actor/api.ts @@ -0,0 +1,61 @@ +import type { KyResponse } from "ky"; +import { getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { + type ActorDeployPayload, + ActorDeployPayloadSchema, + type DeleteActorResponse, + DeleteActorResponseSchema, + type DeployActorResponse, + DeployActorResponseSchema, + validateActorName, +} from "@/core/resources/actor/schema.js"; + +export async function deploySingleActor( + name: string, + payload: ActorDeployPayload, +): Promise { + validateActorName(name); + const input = ActorDeployPayloadSchema.safeParse(payload); + if (!input.success) + throw new SchemaValidationError("Invalid actor deployment", input.error); + + let response: KyResponse; + try { + response = await getAppClient().put(`actors/${encodeURIComponent(name)}`, { + json: input.data, + timeout: false, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, `deploying actor "${name}"`); + } + const result = DeployActorResponseSchema.safeParse(await response.json()); + if (!result.success) + throw new SchemaValidationError( + "Invalid actor deployment response", + result.error, + ); + return result.data; +} + +export async function deleteSingleActor( + name: string, +): Promise { + validateActorName(name); + let response: KyResponse; + try { + response = await getAppClient().delete( + `actors/${encodeURIComponent(name)}`, + { timeout: 60_000 }, + ); + } catch (error) { + throw await ApiError.fromHttpError(error, `deleting actor "${name}"`); + } + const result = DeleteActorResponseSchema.safeParse(await response.json()); + if (!result.success) + throw new SchemaValidationError( + "Invalid actor deletion response", + result.error, + ); + return result.data; +} diff --git a/packages/cli/src/core/resources/actor/config.ts b/packages/cli/src/core/resources/actor/config.ts new file mode 100644 index 000000000..fa2b43262 --- /dev/null +++ b/packages/cli/src/core/resources/actor/config.ts @@ -0,0 +1,62 @@ +import { basename, dirname, join, relative } from "node:path"; +import { globby } from "globby"; +import { + BACKEND_FILE_GLOB, + ENTRY_FILE_GLOB, + ENTRY_IGNORE_DOT_PATHS, +} from "@/core/consts.js"; +import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js"; +import { + type ActorDefinition, + validateActorName, +} from "@/core/resources/actor/schema.js"; +import { pathExists } from "@/core/utils/fs.js"; + +export async function readAllActors( + actorsDir: string, +): Promise { + if (!(await pathExists(actorsDir))) return []; + + const entries = await globby(ENTRY_FILE_GLOB, { + cwd: actorsDir, + absolute: true, + ignore: ENTRY_IGNORE_DOT_PATHS, + }); + const actors: ActorDefinition[] = []; + const names = new Set(); + + for (const entryPath of entries.sort()) { + const actorDir = dirname(entryPath); + const name = relative(actorsDir, actorDir).split(/[/\\]/).join("/"); + if (!name) { + throw new InvalidInputError( + "entry.ts or entry.js found directly in the actors directory — it must be inside a named subfolder", + ); + } + if (name.includes("/")) { + throw new InvalidInputError( + `Invalid actor name '${name}' — actors cannot be nested`, + ); + } + validateActorName(name); + if (names.has(name)) { + throw new ConfigInvalidError( + `Duplicate actor name "${name}" in ${actorsDir}`, + actorsDir, + ); + } + names.add(name); + const filePaths = ( + await globby(BACKEND_FILE_GLOB, { cwd: actorDir, absolute: true }) + ).sort(); + const entry = basename(entryPath) === "entry.js" ? "entry.js" : "entry.ts"; + actors.push({ + name, + entry, + entryPath: join(actorDir, entry), + filePaths, + source: { type: "project" }, + }); + } + return actors; +} diff --git a/packages/cli/src/core/resources/actor/deploy.ts b/packages/cli/src/core/resources/actor/deploy.ts new file mode 100644 index 000000000..97114cd15 --- /dev/null +++ b/packages/cli/src/core/resources/actor/deploy.ts @@ -0,0 +1,76 @@ +import { dirname, relative } from "node:path"; +import { ApiError } from "@/core/errors.js"; +import { deploySingleActor } from "@/core/resources/actor/api.js"; +import type { + ActorDefinition, + ActorOperationError, + SingleActorDeleteResult, + SingleActorDeployResult, +} from "@/core/resources/actor/schema.js"; +import { readTextFile } from "@/core/utils/fs.js"; + +export function actorOperationError( + name: string, + error: unknown, +): ActorOperationError { + return { + name, + status: "error", + error: error instanceof Error ? error.message : String(error), + ...(error instanceof ApiError + ? { statusCode: error.statusCode, requestId: error.requestId } + : {}), + }; +} + +export function describeActorResult( + result: SingleActorDeployResult | SingleActorDeleteResult, +): string { + if (result.status !== "error") return `${result.name}: ${result.status}`; + const context = [ + result.statusCode === undefined ? undefined : `HTTP ${result.statusCode}`, + result.requestId ? `request ${result.requestId}` : undefined, + ] + .filter(Boolean) + .join(", "); + return `${result.name}: error — ${result.error}${context ? ` (${context})` : ""}`; +} + +async function deployOne( + actor: ActorDefinition, +): Promise { + const start = Date.now(); + try { + const actorDir = dirname(actor.entryPath); + const files = await Promise.all( + actor.filePaths.map(async (filePath) => ({ + path: relative(actorDir, filePath).split(/[/\\]/).join("/"), + content: await readTextFile(filePath), + })), + ); + const response = await deploySingleActor(actor.name, { + entry: actor.entry, + files, + }); + return { name: actor.name, ...response, durationMs: Date.now() - start }; + } catch (error) { + return actorOperationError(actor.name, error); + } +} + +export async function deployActorsSequentially( + actors: ActorDefinition[], + options?: { + onStart?: (name: string) => void; + onResult?: (result: SingleActorDeployResult) => void; + }, +): Promise { + const results: SingleActorDeployResult[] = []; + for (const actor of actors) { + options?.onStart?.(actor.name); + const result = await deployOne(actor); + results.push(result); + options?.onResult?.(result); + } + return results; +} diff --git a/packages/cli/src/core/resources/actor/index.ts b/packages/cli/src/core/resources/actor/index.ts new file mode 100644 index 000000000..90b197a7b --- /dev/null +++ b/packages/cli/src/core/resources/actor/index.ts @@ -0,0 +1,5 @@ +export * from "./api.js"; +export * from "./config.js"; +export * from "./deploy.js"; +export * from "./resource.js"; +export * from "./schema.js"; diff --git a/packages/cli/src/core/resources/actor/resource.ts b/packages/cli/src/core/resources/actor/resource.ts new file mode 100644 index 000000000..abc67d189 --- /dev/null +++ b/packages/cli/src/core/resources/actor/resource.ts @@ -0,0 +1,9 @@ +import { readAllActors } from "@/core/resources/actor/config.js"; +import { deployActorsSequentially } from "@/core/resources/actor/deploy.js"; +import type { ActorDefinition } from "@/core/resources/actor/schema.js"; +import type { Resource } from "@/core/resources/types.js"; + +export const actorResource: Resource = { + readAll: readAllActors, + push: deployActorsSequentially, +}; diff --git a/packages/cli/src/core/resources/actor/schema.ts b/packages/cli/src/core/resources/actor/schema.ts new file mode 100644 index 000000000..833e8bfe9 --- /dev/null +++ b/packages/cli/src/core/resources/actor/schema.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import { SchemaValidationError } from "@/core/errors.js"; + +// Actor names become strict-mode class bindings. +const RESERVED_NAMES = new Set( + ( + "await break case catch class const continue debugger default delete do else " + + "enum export extends false finally for function if import in instanceof let " + + "new null return static super switch this throw true try typeof var void " + + "while with yield implements interface package private protected public " + + "eval arguments" + ).split(" "), +); + +export const ActorNameSchema = z + .string() + .regex( + /^[A-Za-z_][A-Za-z0-9_]{0,127}$(?![\s\S])/, + "Actor names must be JavaScript identifiers of at most 128 characters (letters, digits, and underscores)", + ) + .refine((name) => !RESERVED_NAMES.has(name), { + message: "Actor names cannot be JavaScript reserved words", + }); + +export function validateActorName(name: string): void { + const result = ActorNameSchema.safeParse(name); + if (!result.success) { + throw new SchemaValidationError( + `Invalid actor name '${name}'`, + result.error, + ); + } +} + +const ActorDefinitionSchema = z.object({ + name: ActorNameSchema, + entry: z.enum(["entry.ts", "entry.js"]), + entryPath: z.string().min(1), + filePaths: z.array(z.string()).min(1), + source: z.object({ type: z.literal("project") }), +}); + +export const ActorDeployPayloadSchema = z.object({ + entry: z.enum(["entry.ts", "entry.js"]), + files: z + .array(z.object({ path: z.string().min(1), content: z.string() })) + .min(1), +}); + +export const DeployActorResponseSchema = z.object({ + status: z.enum(["deployed", "unchanged"]), + warnings: z.array(z.string()).optional().default([]), +}); + +export const DeleteActorResponseSchema = z + .object({ status: z.literal("deleted"), handler_name: ActorNameSchema }) + .transform((data) => ({ + status: data.status, + handlerName: data.handler_name, + })); + +export type ActorDefinition = z.infer; +export type ActorDeployPayload = z.infer; +export type DeployActorResponse = z.infer; +export type DeleteActorResponse = z.infer; + +export interface ActorOperationError { + name: string; + status: "error"; + error: string; + statusCode?: number; + requestId?: string; +} + +export type SingleActorDeployResult = + | ActorOperationError + | (DeployActorResponse & { name: string; durationMs: number }); + +export type SingleActorDeleteResult = + | ActorOperationError + | { name: string; status: "deleted" | "not_found" }; diff --git a/packages/cli/src/core/resources/index.ts b/packages/cli/src/core/resources/index.ts index a8b80eaff..74bc62530 100644 --- a/packages/cli/src/core/resources/index.ts +++ b/packages/cli/src/core/resources/index.ts @@ -1,3 +1,4 @@ +export * from "./actor/index.js"; export * from "./agent/index.js"; export * from "./auth-config/index.js"; export * from "./connector/index.js"; diff --git a/packages/cli/src/core/types/generator.ts b/packages/cli/src/core/types/generator.ts index 6297f9c1b..b5423cb14 100644 --- a/packages/cli/src/core/types/generator.ts +++ b/packages/cli/src/core/types/generator.ts @@ -3,6 +3,7 @@ import type { JSONSchema4 } from "json-schema"; import { compile } from "json-schema-to-typescript"; import { getTypesOutputPath } from "@/core/config.js"; import { TypeGenerationError } from "@/core/errors.js"; +import type { ActorDefinition } from "@/core/resources/actor/index.js"; import type { AgentConfig } from "@/core/resources/agent/index.js"; import type { ConnectorResource } from "@/core/resources/connector/index.js"; import type { Entity } from "@/core/resources/entity/index.js"; @@ -13,6 +14,7 @@ interface GenerateTypesInput { projectRoot: string; entities: Entity[]; functions: BackendFunction[]; + actors: ActorDefinition[]; agents: AgentConfig[]; connectors: ConnectorResource[]; } @@ -26,10 +28,12 @@ const EMPTY_TEMPLATE = stripIndent` // Auto-generated by Base44 CLI - DO NOT EDIT // Regenerate with: base44 types // - // No entities, functions, agents, or connectors found in project. - // Add resources to base44/entities/, base44/functions/, base44/agents/, or base44/connectors/ + // No entities, functions, actors, agents, or connectors found in project. + // Add resources to base44/entities/, base44/functions/, base44/actors/, base44/agents/, or base44/connectors/ // and run \`base44 types generate\` again. + import '@base44/sdk'; + declare module '@base44/sdk' { // No types to augment - add resources and regenerate } @@ -46,11 +50,12 @@ export async function generateTypesFile( } async function generateContent(input: GenerateTypesInput): Promise { - const { entities, functions, agents, connectors } = input; + const { entities, functions, actors, agents, connectors } = input; if ( !entities.length && !functions.length && + !actors.length && !agents.length && !connectors.length ) { @@ -68,6 +73,7 @@ async function generateContent(input: GenerateTypesInput): Promise { entities.map((e) => `"${e.name}": ${toPascalCase(e.name)};`), ], ["FunctionNameRegistry", functions.map((f) => `"${f.name}": true;`)], + ["ActorNameRegistry", actors.map((actor) => `"${actor.name}": true;`)], ["AgentNameRegistry", agents.map((a) => `"${a.name}": true;`)], ["ConnectorTypeRegistry", connectors.map((c) => `"${c.type}": true;`)], ]; @@ -79,6 +85,7 @@ async function generateContent(input: GenerateTypesInput): Promise { return [ HEADER, + "import '@base44/sdk';", entityInterfaces.join("\n\n"), source` declare module '@base44/sdk' { diff --git a/packages/cli/tests/cli/actors.spec.ts b/packages/cli/tests/cli/actors.spec.ts new file mode 100644 index 000000000..d19bb1080 --- /dev/null +++ b/packages/cli/tests/cli/actors.spec.ts @@ -0,0 +1,445 @@ +import { cp, mkdir, rename, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("actors commands", () => { + const t = setupCLITests(); + + it("keeps function pruning on the platform's filtered function list", async () => { + await t.givenLoggedInWithProject(fixture("with-functions-and-entities")); + await cp( + fixture("with-actors/base44/actors"), + join(t.getTempDir(), "project/base44/actors"), + { recursive: true }, + ); + const deployed: string[] = []; + const deleted: string[] = []; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/backend-functions/:name`, + (req, res) => { + deployed.push(String(req.params.name)); + res.json({ status: "deployed" }); + }, + ); + t.api.mockFunctionsList({ + functions: [ + { + name: "process-order", + deployment_id: "function-current", + entry: "entry.ts", + files: [], + automations: [], + }, + { + name: "removed", + deployment_id: "function-removed", + entry: "entry.ts", + files: [], + automations: [], + }, + ], + }); + t.api.mockRoute( + "DELETE", + `/api/apps/${t.api.appId}/backend-functions/:name`, + (req, res) => { + deleted.push(String(req.params.name)); + res.status(204).end(); + }, + ); + const result = await t.run("functions", "deploy", "--force"); + t.expectResult(result).toSucceed(); + expect(deployed).toEqual(["process-order"]); + expect(deleted).toEqual(["removed"]); + expect(await t.fileExists("base44/actors/ChatRoom/entry.ts")).toBe(true); + }); + + it("deploys all actors with exact folder-local payloads and preserves warnings", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + const requests: { name: string; body: unknown }[] = []; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/:name`, + (req, res) => { + requests.push({ name: String(req.params.name), body: req.body }); + res.json({ + status: req.params.name === "ChatRoom" ? "deployed" : "unchanged", + warnings: ["Deployment warning"], + }); + }, + ); + + const result = await t.run("actors", "deploy", "--json"); + + t.expectResult(result).toSucceed(); + expect(requests.map(({ name }) => name)).toEqual(["ChatRoom", "Counter"]); + expect(requests[0].body).toEqual({ + entry: "entry.ts", + files: await Promise.all( + ["data.json", "deno.jsonc", "entry.ts", "lib/message.ts"].map( + async (path) => ({ + path, + content: await t.readProjectFile(`base44/actors/ChatRoom/${path}`), + }), + ), + ), + }); + expect(requests[1].body).toEqual({ + entry: "entry.js", + files: [ + { + path: "entry.js", + content: await t.readProjectFile("base44/actors/Counter/entry.js"), + }, + ], + }); + expect(JSON.parse(result.stdout)).toEqual({ + actors: [ + { + name: "ChatRoom", + status: "deployed", + warnings: ["Deployment warning"], + durationMs: expect.any(Number), + }, + { + name: "Counter", + status: "unchanged", + warnings: ["Deployment warning"], + durationMs: expect.any(Number), + }, + ], + summary: { deployed: 1, unchanged: 1, failed: 0 }, + }); + expect(result.stderr).toContain("Deployment warning"); + }); + + it.each([ + "file", + "directory", + ])("deploys actor content through a linked %s", async (type) => { + await t.givenLoggedInWithProject(fixture("with-actors")); + const root = join(t.getTempDir(), "project"); + const actorDir = join(root, "base44/actors/ChatRoom"); + const files = await Promise.all( + ["data.json", "deno.jsonc", "entry.ts", "lib/message.ts"].map( + async (path) => ({ + path, + content: await t.readProjectFile(`base44/actors/ChatRoom/${path}`), + }), + ), + ); + const source = + type === "file" ? join(actorDir, "lib/message.ts") : actorDir; + const target = join( + root, + type === "file" ? "shared-message.ts" : "shared-actor", + ); + await rename(source, target); + await symlink(target, source, type === "file" ? "file" : "dir"); + let payload: unknown; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/ChatRoom`, + (req, res) => { + payload = req.body; + res.json({ status: "deployed" }); + }, + ); + + const result = await t.run("actors", "deploy", "ChatRoom", "--json"); + + t.expectResult(result).toSucceed(); + expect(payload).toEqual({ entry: "entry.ts", files }); + }); + + it("selects and deduplicates names supplied with spaces and commas", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + const names: string[] = []; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/:name`, + (req, res) => { + names.push(String(req.params.name)); + res.json({ status: "deployed" }); + }, + ); + const result = await t.run( + "actors", + "deploy", + "Counter,Counter", + "Counter", + ); + t.expectResult(result).toSucceed(); + expect(names).toEqual(["Counter"]); + }); + + it.each([ + "Missing", + "bad-name", + "class", + "../ChatRoom", + ])("validates every requested name before deployment: %s", async (name) => { + await t.givenLoggedInWithProject(fixture("with-actors")); + let writes = 0; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/:name`, + (_req, res) => { + writes++; + res.json({ status: "deployed" }); + }, + ); + const result = await t.run("actors", "deploy", "ChatRoom", name, "--json"); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain(name); + expect(writes).toBe(0); + }); + + it.each([ + { + status: 400, + body: { detail: "Invalid actor source" }, + message: "Invalid actor source", + }, + { + status: 403, + body: { detail: "Publishing denied" }, + message: "Publishing denied", + }, + { + status: 200, + body: { status: "unknown" }, + message: "Invalid actor deployment response", + }, + ])("continues after a failed actor and returns all outcomes ($status)", async ({ + status, + body, + message, + }) => { + await t.givenLoggedInWithProject(fixture("with-actors")); + const names: string[] = []; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/:name`, + (req, res) => { + names.push(String(req.params.name)); + if (req.params.name === "ChatRoom") + res.set("x-request-id", "actor-request").status(status).json(body); + else res.json({ status: "deployed" }); + }, + ); + const result = await t.run("actors", "deploy", "--json"); + t.expectResult(result).toFail(); + expect(names).toEqual(["ChatRoom", "Counter"]); + const error = JSON.parse(result.stdout); + expect(error.code).toBe("RESOURCE_DEPLOYMENT_FAILED"); + expect(error.details.join("\n")).toContain(message); + expect(error.details.join("\n")).toContain("Counter: deployed"); + if (status !== 200) { + expect(error.details.join("\n")).toContain(`HTTP ${status}`); + expect(error.details.join("\n")).toContain("actor-request"); + } + }); + + it("uses actorsDir relative to config.jsonc and respects --app-id", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const root = t.getTempDir(); + await cp(fixture("with-actors"), root, { recursive: true }); + await rename(join(root, "base44/actors"), join(root, "rooms")); + await writeFile( + join(root, "base44/config.jsonc"), + JSON.stringify({ name: "Custom actors", actorsDir: "../rooms" }), + ); + const names: string[] = []; + t.api.mockRoute("PUT", "/api/apps/overridden/actors/:name", (req, res) => { + names.push(String(req.params.name)); + res.json({ status: "deployed" }); + }); + const result = await t.run( + "actors", + "deploy", + "ChatRoom", + "--app-id", + "overridden", + ); + t.expectResult(result).toSucceed(); + expect(names).toEqual(["ChatRoom"]); + }); + + it("fails without a checkout even with --app-id", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const result = await t.run("actors", "deploy", "--app-id", t.api.appId); + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Project root not found"); + }); + + it("returns an empty successful result when no actors exist", async () => { + await t.givenLoggedInWithProject(fixture("basic")); + const result = await t.run("actors", "deploy", "--json"); + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + actors: [], + summary: { deployed: 0, unchanged: 0, failed: 0 }, + }); + }); + + it.each([ + "local", + "plugin", + ])("rejects a collision with a %s backend function before deployment", async (source) => { + await t.givenLoggedInWithProject( + fixture(source === "plugin" ? "with-config-plugins" : "with-actors"), + ); + const root = join(t.getTempDir(), "project"); + const name = source === "plugin" ? "crm__syncCustomer" : "ChatRoom"; + if (source === "plugin") { + await cp( + fixture("with-actors/base44/actors/ChatRoom"), + join(root, "base44/actors", name), + { recursive: true }, + ); + } else { + await mkdir(join(root, "base44/functions", name), { recursive: true }); + await cp( + fixture("full-project/base44/functions/hello/entry.ts"), + join(root, "base44/functions", name, "entry.ts"), + ); + } + let writes = 0; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/:name`, + (_req, res) => { + writes++; + res.json({ status: "deployed" }); + }, + ); + const result = await t.run("actors", "deploy"); + t.expectResult(result).toFail(); + t.expectResult(result).toContain("both a backend function and an actor"); + expect(writes).toBe(0); + }); + + it("does not load or validate plugin actors", async () => { + await t.givenLoggedInWithProject(fixture("with-config-plugins")); + await cp( + fixture("actor-validation"), + join(t.getTempDir(), "project/plugins/crm/base44/actors"), + { recursive: true }, + ); + const result = await t.run("actors", "deploy", "--json"); + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout).actors).toEqual([]); + }); + + it("deletes remote actors without a checkout and treats 404 as already absent", async () => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const names: string[] = []; + t.api.mockRoute( + "DELETE", + `/api/apps/${t.api.appId}/actors/:name`, + (req, res) => { + names.push(String(req.params.name)); + if (req.params.name === "Missing") + res.status(404).json({ detail: "Not found" }); + else res.json({ status: "deleted", handler_name: req.params.name }); + }, + ); + const result = await t.run( + "actors", + "delete", + "ChatRoom,Missing", + "ChatRoom", + "--app-id", + t.api.appId, + "--json", + ); + t.expectResult(result).toSucceed(); + expect(names).toEqual(["ChatRoom", "Missing"]); + expect(JSON.parse(result.stdout)).toEqual({ + actors: [ + { name: "ChatRoom", status: "deleted" }, + { name: "Missing", status: "not_found" }, + ], + summary: { deleted: 1, notFound: 1, failed: 0 }, + }); + }); + + it("preserves local source files after deletion", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + const before = await t.readProjectFile("base44/actors/ChatRoom/entry.ts"); + t.api.mockRoute( + "DELETE", + `/api/apps/${t.api.appId}/actors/ChatRoom`, + (_req, res) => res.json({ status: "deleted", handler_name: "ChatRoom" }), + ); + const result = await t.run("actors", "delete", "ChatRoom"); + t.expectResult(result).toSucceed(); + expect(await t.readProjectFile("base44/actors/ChatRoom/entry.ts")).toBe( + before, + ); + }); + + it.each([ + { names: [] }, + { names: ["ChatRoom", "bad-name"] }, + { names: [","] }, + ])("validates delete arguments before mutation: $names", async ({ + names, + }) => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + let writes = 0; + t.api.mockRoute( + "DELETE", + `/api/apps/${t.api.appId}/actors/:name`, + (_req, res) => { + writes++; + res.json({}); + }, + ); + const result = await t.run( + "actors", + "delete", + ...names, + "--app-id", + t.api.appId, + "--json", + ); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toBeTruthy(); + expect(writes).toBe(0); + }); + + it.each([ + 403, 200, + ])("continues delete after an HTTP or schema error (%s)", async (status) => { + await t.givenLoggedIn({ email: "test@example.com", name: "Test User" }); + const names: string[] = []; + t.api.mockRoute( + "DELETE", + `/api/apps/${t.api.appId}/actors/:name`, + (req, res) => { + names.push(String(req.params.name)); + if (req.params.name === "ChatRoom") + res.status(status).json({ detail: "Publishing denied" }); + else res.json({ status: "deleted", handler_name: req.params.name }); + }, + ); + const result = await t.run( + "actors", + "delete", + "ChatRoom", + "Counter", + "--app-id", + t.api.appId, + "--json", + ); + t.expectResult(result).toFail(); + expect(names).toEqual(["ChatRoom", "Counter"]); + expect(JSON.parse(result.stdout).details.join("\n")).toContain( + "Counter: deleted", + ); + }); +}); diff --git a/packages/cli/tests/cli/actors_deploy_all.spec.ts b/packages/cli/tests/cli/actors_deploy_all.spec.ts new file mode 100644 index 000000000..59d375af1 --- /dev/null +++ b/packages/cli/tests/cli/actors_deploy_all.spec.ts @@ -0,0 +1,109 @@ +import { cp } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("actors in unified deploy", () => { + const t = setupCLITests(); + + it("deploys an actor-only project", async () => { + await t.givenLoggedInWithProject(fixture("with-actors")); + const actors: string[] = []; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/:name`, + (req, res) => { + actors.push(String(req.params.name)); + res.json({ status: "deployed", warnings: ["Check deployment"] }); + }, + ); + t.api.mockConnectorsList({ integrations: [] }); + t.api.mockStripeStatus({ stripe_mode: null }); + const result = await t.run("deploy", "-y"); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("2 actors"); + t.expectResult(result).toContain("Check deployment"); + t.expectResult(result).toContain("App deployed successfully"); + expect(actors).toEqual(["ChatRoom", "Counter"]); + }); + + it.each([ + "success", + "function", + "actor", + ])("runs resources in order and stops after a failed stage: %s", async (failure) => { + await t.givenLoggedInWithProject(fixture("full-project")); + await cp( + fixture("with-actors/base44/actors"), + join(t.getTempDir(), "project/base44/actors"), + { recursive: true }, + ); + await cp( + fixture("with-functions-and-entities/base44/functions"), + join(t.getTempDir(), "project/base44/functions"), + { recursive: true }, + ); + const stages: string[] = []; + t.api.mockEntitiesPush({ created: ["Task"], updated: [], deleted: [] }); + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/backend-functions/:name`, + (req, res) => { + stages.push(`function:${req.params.name}`); + if (failure === "function" && req.params.name === "hello") + res.status(400).json({ detail: "Function failed" }); + else res.json({ status: "deployed" }); + }, + ); + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/actors/:name`, + (req, res) => { + stages.push(`actor:${req.params.name}`); + if (failure === "actor" && req.params.name === "ChatRoom") + res.status(400).json({ detail: "Actor failed" }); + else res.json({ status: "deployed" }); + }, + ); + t.api.mockRoute( + "GET", + `/api/apps/${t.api.appId}/external-auth/list`, + (_req, res) => { + stages.push("connectors"); + res.json({ integrations: [] }); + }, + ); + t.api.mockStripeStatus({ stripe_mode: null }); + t.api.mockSiteDeploy({ app_url: "https://test.base44.app" }); + + const result = await t.run("deploy", "-y", "--json"); + + expect(stages.slice(0, 2).sort()).toEqual([ + "function:hello", + "function:process-order", + ]); + if (failure === "success") { + t.expectResult(result).toSucceed(); + expect(stages.slice(2)).toEqual([ + "actor:ChatRoom", + "actor:Counter", + "connectors", + ]); + } else { + t.expectResult(result).toFail(); + const error = JSON.parse(result.stdout); + expect(error.code).toBe("RESOURCE_DEPLOYMENT_FAILED"); + expect(error.details.join("\n")).toContain("Entities synced: 1"); + expect(error.details.join("\n")).toContain( + "Function process-order: deployed", + ); + expect(stages).not.toContain("connectors"); + t.expectResult(result).toNotContain("App deployed successfully"); + if (failure === "function") expect(stages).toHaveLength(2); + else { + expect(stages.slice(2)).toEqual(["actor:ChatRoom", "actor:Counter"]); + expect(error.details.join("\n")).toContain("Counter: deployed"); + } + } + }); +}); diff --git a/packages/cli/tests/cli/branch_scope.spec.ts b/packages/cli/tests/cli/branch_scope.spec.ts index e1ea0ef25..98fea5ebc 100644 --- a/packages/cli/tests/cli/branch_scope.spec.ts +++ b/packages/cli/tests/cli/branch_scope.spec.ts @@ -83,6 +83,11 @@ describe("branch name targeting", () => { it.each([ { args: ["sandbox", "ls", "--branch", ""], error: "must not be empty" }, { args: ["functions", "pull", "--branch", "main"], error: "not supported" }, + { args: ["actors", "deploy", "--branch", "main"], error: "not supported" }, + { + args: ["actors", "delete", "ChatRoom", "--branch", "feature"], + error: "not supported", + }, ])("validates flags before authentication: $error", async ({ args, error, diff --git a/packages/cli/tests/cli/dev-actors.spec.ts b/packages/cli/tests/cli/dev-actors.spec.ts new file mode 100644 index 000000000..ce1da5594 --- /dev/null +++ b/packages/cli/tests/cli/dev-actors.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { waitForDevServer } from "./testkit/dev-utils.js"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("local actor requests", () => { + const t = setupCLITests(); + + it.each([ + "basic", + "with-actors", + ])("blocks actor requests without forwarding them (%s)", async (project) => { + await t.givenLoggedInWithProject(fixture(project)); + const handle = await t.runLive("dev"); + const url = await waitForDevServer(handle); + const response = await fetch( + `${url}/api/apps/${t.api.appId}/actors/ChatRoom/connection-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ room: "lobby", connection_id: "local-test" }), + }, + ); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "Actors are not available in local development", + }); + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + "Actors are not available in local development", + ); + t.expectResult(result).toNotContain("passing call to production"); + }); +}); diff --git a/packages/cli/tests/cli/types_generate.spec.ts b/packages/cli/tests/cli/types_generate.spec.ts index 9232d9e13..621dee3b3 100644 --- a/packages/cli/tests/cli/types_generate.spec.ts +++ b/packages/cli/tests/cli/types_generate.spec.ts @@ -103,7 +103,7 @@ describe("types generate command", () => { const typesContent = await t.readProjectFile("base44/.types/types.d.ts"); expect(typesContent).not.toBeNull(); expect(typesContent).toContain( - "No entities, functions, agents, or connectors found", + "No entities, functions, actors, agents, or connectors found", ); }); diff --git a/packages/cli/tests/core/actor-config.spec.ts b/packages/cli/tests/core/actor-config.spec.ts new file mode 100644 index 000000000..f7f5e7eb6 --- /dev/null +++ b/packages/cli/tests/core/actor-config.spec.ts @@ -0,0 +1,80 @@ +import { basename, join, relative, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readProjectConfig } from "@/core/project/config.js"; +import { readAllActors } from "@/core/resources/actor/config.js"; +import { ActorNameSchema } from "@/core/resources/actor/schema.js"; + +const fixtures = resolve(__dirname, "../fixtures"); +const actorDir = join(fixtures, "with-actors/base44/actors"); + +describe("actor discovery", () => { + it("reads TS and JS actors with folder-local source and config files", async () => { + const actors = await readAllActors(actorDir); + expect( + actors.map(({ name, entry, source }) => ({ name, entry, source })), + ).toEqual([ + { name: "ChatRoom", entry: "entry.ts", source: { type: "project" } }, + { name: "Counter", entry: "entry.js", source: { type: "project" } }, + ]); + expect( + actors[0].filePaths.map((path) => + relative(join(actorDir, "ChatRoom"), path) + .split(/[/\\\\]/) + .join("/"), + ), + ).toEqual(["data.json", "deno.jsonc", "entry.ts", "lib/message.ts"]); + }); + + it("returns no actors when the directory is absent", async () => { + expect(await readAllActors(join(fixtures, "basic/base44/actors"))).toEqual( + [], + ); + }); + + it.each([ + ["root", "named subfolder"], + ["nested", "cannot be nested"], + ["duplicate", "Duplicate actor name"], + ["invalid", "Invalid actor name"], + ["reserved", "Invalid actor name"], + ])("rejects %s entries", async (name, error) => { + await expect( + readAllActors(join(fixtures, "actor-validation", name)), + ).rejects.toThrow(error); + }); + + it.each([ + "", + "1Room", + "Room/Child", + "Room-Name", + "class", + "eval", + "arguments", + "await", + "Room\n", + "a".repeat(129), + ])("rejects invalid name %j", (name) => { + expect(ActorNameSchema.safeParse(name).success).toBe(false); + }); + + it.each(["Room", "_Room2", "a".repeat(128)])("accepts name %s", (name) => { + expect(ActorNameSchema.safeParse(name).success).toBe(true); + }); +}); + +describe("actors in project resources", () => { + it("keeps old projects compatible", async () => { + const data = await readProjectConfig(join(fixtures, "basic")); + expect(data.project.actorsDir).toBe("actors"); + expect(data.actors).toEqual([]); + }); + + it("loads folder names from the project", async () => { + const data = await readProjectConfig(join(fixtures, "with-actors")); + expect(data.actors.map((actor) => basename(actor.entryPath))).toEqual([ + "entry.ts", + "entry.js", + ]); + }); +}); diff --git a/packages/cli/tests/core/actor-types.spec.ts b/packages/cli/tests/core/actor-types.spec.ts new file mode 100644 index 000000000..e1bcdefad --- /dev/null +++ b/packages/cli/tests/core/actor-types.spec.ts @@ -0,0 +1,69 @@ +import { cp, mkdtemp, readFile, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import ts from "typescript"; +import { afterEach, describe, expect, it } from "vitest"; +import { readProjectConfig } from "@/core/project/config.js"; +import { generateTypesFile } from "@/core/types/generator.js"; + +const fixtures = resolve(__dirname, "../fixtures"); +const require = createRequire(import.meta.url); +const sdkTypes = require.resolve("@base44/sdk").replace(/\.js$/, ".d.ts"); +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function generateProject(mixed: boolean) { + const root = await mkdtemp(join(tmpdir(), "base44-actor-types-")); + tempDirs.push(root); + await cp(join(fixtures, "with-actors"), root, { recursive: true }); + if (mixed) + await cp(join(fixtures, "with-types-resources"), root, { recursive: true }); + const messagesPath = join(root, "base44/.types/actor-messages.d.ts"); + const messagesBefore = await readFile(messagesPath, "utf8"); + const { project, ...resources } = await readProjectConfig(root); + await generateTypesFile({ projectRoot: project.root, ...resources }); + expect(await readFile(messagesPath, "utf8")).toBe(messagesBefore); + return root; +} + +function compile(root: string) { + const program = ts.createProgram({ + rootNames: [ + join(root, "check-types.ts"), + join(root, "base44/.types/types.d.ts"), + join(root, "base44/.types/actor-messages.d.ts"), + ], + options: { + noEmit: true, + strict: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + esModuleInterop: true, + skipLibCheck: true, + types: [], + paths: { "@base44/sdk": [sdkTypes] }, + }, + }); + const errors = ts + .getPreEmitDiagnostics(program) + .map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")); + expect(errors).toEqual([]); +} + +describe("generated actor types", () => { + it.each([ + false, + true, + ])("augments the SDK and preserves user declarations (mixed=%s)", async (mixed) => { + compile(await generateProject(mixed)); + }); +}); diff --git a/packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.js b/packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.js new file mode 100644 index 000000000..64d5049cc --- /dev/null +++ b/packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.js @@ -0,0 +1 @@ +export default class Room {} diff --git a/packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.ts b/packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.ts new file mode 100644 index 000000000..64d5049cc --- /dev/null +++ b/packages/cli/tests/fixtures/actor-validation/duplicate/Room/entry.ts @@ -0,0 +1 @@ +export default class Room {} diff --git a/packages/cli/tests/fixtures/actor-validation/invalid/bad-name/entry.ts b/packages/cli/tests/fixtures/actor-validation/invalid/bad-name/entry.ts new file mode 100644 index 000000000..64d5049cc --- /dev/null +++ b/packages/cli/tests/fixtures/actor-validation/invalid/bad-name/entry.ts @@ -0,0 +1 @@ +export default class Room {} diff --git a/packages/cli/tests/fixtures/actor-validation/nested/Outer/Inner/entry.ts b/packages/cli/tests/fixtures/actor-validation/nested/Outer/Inner/entry.ts new file mode 100644 index 000000000..d2b600f42 --- /dev/null +++ b/packages/cli/tests/fixtures/actor-validation/nested/Outer/Inner/entry.ts @@ -0,0 +1 @@ +export default class Inner {} diff --git a/packages/cli/tests/fixtures/actor-validation/reserved/class/entry.ts b/packages/cli/tests/fixtures/actor-validation/reserved/class/entry.ts new file mode 100644 index 000000000..64d5049cc --- /dev/null +++ b/packages/cli/tests/fixtures/actor-validation/reserved/class/entry.ts @@ -0,0 +1 @@ +export default class Room {} diff --git a/packages/cli/tests/fixtures/actor-validation/root/entry.ts b/packages/cli/tests/fixtures/actor-validation/root/entry.ts new file mode 100644 index 000000000..fbb501d8d --- /dev/null +++ b/packages/cli/tests/fixtures/actor-validation/root/entry.ts @@ -0,0 +1 @@ +export default class Root {} diff --git a/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc b/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc new file mode 100644 index 000000000..8367f6b27 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/.app.jsonc @@ -0,0 +1 @@ +{ "id": "test-app-id" } diff --git a/packages/cli/tests/fixtures/with-actors/base44/.types/actor-messages.d.ts b/packages/cli/tests/fixtures/with-actors/base44/.types/actor-messages.d.ts new file mode 100644 index 000000000..fbbf89a39 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/.types/actor-messages.d.ts @@ -0,0 +1,7 @@ +import "@base44/sdk"; + +declare module "@base44/sdk" { + interface ActorRegistry { + ChatRoom: { toClient: { type: "hello" }; toServer: { type: "message"; text: string } }; + } +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/.scratch/entry.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/.scratch/entry.ts new file mode 100644 index 000000000..58d7285b3 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/.scratch/entry.ts @@ -0,0 +1 @@ +This ignored actor is intentionally invalid. diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/README.md b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/README.md new file mode 100644 index 000000000..4b9ca2c97 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/README.md @@ -0,0 +1 @@ +Not a deploy input. diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/data.json b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/data.json new file mode 100644 index 000000000..1c9a99d1b --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/data.json @@ -0,0 +1 @@ +{ "limit": 10 } diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/deno.jsonc b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/deno.jsonc new file mode 100644 index 000000000..3add7d009 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/deno.jsonc @@ -0,0 +1 @@ +{ "imports": {} } diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts new file mode 100644 index 000000000..2d6b8b06a --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/entry.ts @@ -0,0 +1,9 @@ +import { Actor } from "base44:runtime/actors"; +import { greeting } from "./lib/message.ts"; + +export default class ChatRoom extends Actor { + handleConnect(conn) { conn.send(greeting); } + handleMessage(conn, message) { this.broadcast(message); } + handleClose() {} + handleTick() {} +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/lib/message.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/lib/message.ts new file mode 100644 index 000000000..d890f80f1 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/ChatRoom/lib/message.ts @@ -0,0 +1 @@ +export const greeting = { type: "hello" }; diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/Counter/entry.js b/packages/cli/tests/fixtures/with-actors/base44/actors/Counter/entry.js new file mode 100644 index 000000000..343e3d968 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/Counter/entry.js @@ -0,0 +1,7 @@ +import { Actor } from "base44:runtime/actors"; + +export default class Counter extends Actor { + handleConnect(conn) { conn.send({ count: 0 }); } + handleMessage(conn, message) { this.broadcast(message); } + handleClose() {} +} diff --git a/packages/cli/tests/fixtures/with-actors/base44/actors/shared.ts b/packages/cli/tests/fixtures/with-actors/base44/actors/shared.ts new file mode 100644 index 000000000..a59d6e830 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/actors/shared.ts @@ -0,0 +1 @@ +export const outsideActorFolder = true; diff --git a/packages/cli/tests/fixtures/with-actors/base44/config.jsonc b/packages/cli/tests/fixtures/with-actors/base44/config.jsonc new file mode 100644 index 000000000..20398d05e --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/base44/config.jsonc @@ -0,0 +1 @@ +{ "name": "Actor Test Project" } diff --git a/packages/cli/tests/fixtures/with-actors/check-actor-client.ts b/packages/cli/tests/fixtures/with-actors/check-actor-client.ts new file mode 100644 index 000000000..81acb4cc3 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/check-actor-client.ts @@ -0,0 +1,14 @@ +import { createClient } from "@base44/sdk"; + +const client = createClient({ appId: "actor-type-test" }); +const room = client.actors.ChatRoom("lobby").connect(); +room.send({ type: "message", text: "hello" }); +// @ts-expect-error Message types come from the user-owned ActorRegistry. +room.send({ type: "invalid" }); +room.subscribe((message) => { + const type: "hello" = message.type; + return type; +}); +client.actors.Counter("counter").connect().close(); +// The SDK intentionally permits dynamically named actors. +client.actors.DynamicRoom("lobby").connect().close(); diff --git a/packages/cli/tests/fixtures/with-actors/check-types.ts b/packages/cli/tests/fixtures/with-actors/check-types.ts new file mode 100644 index 000000000..180df6442 --- /dev/null +++ b/packages/cli/tests/fixtures/with-actors/check-types.ts @@ -0,0 +1,7 @@ +import { createClient, type ActorNameRegistry } from "@base44/sdk"; + +export const client = createClient({ appId: "actor-type-test" }); +export const room: keyof ActorNameRegistry = "ChatRoom"; +export const counter: keyof ActorNameRegistry = "Counter"; +// @ts-expect-error The generated registry contains only discovered actors. +export const missing: keyof ActorNameRegistry = "Missing";