Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions docs/versions.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Versions

**Keywords:** versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, backend functions, backend_bundles, actors, Durable Objects, functions-compiler, shards, raw payloads, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox
**Keywords:** versions, publish, deployment, rollback, artifact set, sha256, digest, staged upload, presigned, x-amz-checksum-sha256, entities, agents, backend functions, backend_bundles, actors, Durable Objects, functions-compiler, shards, raw payloads, workflows, provenance, commit, idempotency key, step, BASE44_VERSIONS_API, env gate, build sandbox

A **version** is one immutable thing a build produced — every frontend file, plus the entity and agent payloads the app declares. A **deployment** is a version made live at an environment. Recording one and serving one are separate acts, which is what makes a rollback a deploy of an older version rather than a second code path.
A **version** is one immutable thing a build produced — every frontend file, plus the entity, agent and workflow payloads the app declares. A **deployment** is a version made live at an environment. Recording one and serving one are separate acts, which is what makes a rollback a deploy of an older version rather than a second code path.

This lives in `src/core/version/`: `artifacts.ts` (the build-output walk and the raw resource reads), `functions.ts` (compiling the backend functions), `actors.ts` (compiling the actors), `api.ts` (the three HTTP calls), `publish.ts` (orchestration, and the step names behind the shared tagger in `core/errors.ts`), `gate.ts` (the env gate), `schema.ts` (wire types).

Expand All @@ -20,7 +20,7 @@ It is **not** `src/core/site/` — see [Deployments](deployments.md). That lane

`createVersion(artifacts, options)` in `api.ts` — three calls, in this order and no other, so nothing is recorded until the bytes are in place:

1. **Declare.** `POST versions` with `assets` (path, size, digest per file), optionally a `site_worker` beside it, the compiled `backend_bundles` and `actors`, plus the raw `entities` and `agents` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order: the assets, the Worker's modules, one per bundle, then one per actor. Each kind comes after the ones before it, so an app gaining one moves no existing slot.
1. **Declare.** `POST versions` with `assets` (path, size, digest per file), optionally a `site_worker` beside it, the compiled `backend_bundles` and `actors`, plus the raw `entities`, `agents` and `workflows` payloads and `source_commit`. The response carries a `session_id` and one presigned PUT per declared file, in declared order: the assets, the Worker's modules, one per bundle, then one per actor. Each kind comes after the ones before it, so an app gaining one moves no existing slot.
2. **Upload.** `putPresigned` per file — the same PUT the static deployments lane uses, same ky retry policy. Each PUT sends the server's `Content-Type` **and** its `x-amz-checksum-sha256` verbatim; deriving either locally would 403 on any mapping difference. Uploads are paired with declared files **by position**, not by path: the frontend and the Worker's modules are separate namespaces, so the same name can appear in both and mean two different files.
3. **Finalize.** `POST versions/{session_id}/finalize`, no body. The set was fixed at declare, so there is nothing left for the caller to change.

Expand Down Expand Up @@ -77,7 +77,7 @@ The compiler is bundled like the rest of the CLI, and imports `esbuild` and `@de

The entry is always `base44/actors/<name>/entry.ts`, an `entry.js` included, and that path is not cosmetic: it is how the compiler knows the entry is an actor. That is why this does not go through `cfwBundleInput`, which flattens an entry that reaches nothing to `main.ts`. The files are the ones the entry reaches among its own folder and the shared modules, keyed at `base44/actors/<name>/` and `base44/shared/` wherever the project keeps them — which is how apper assembles an actor, so `../../shared/x.ts` resolves and a reach into another actor or a function does not.

`actors` is always sent, `[]` for an app with none, for the reason `backend_bundles` is: an absent field is what a CLI from before actors sends, and the platform refuses it (409 `actors_undeclared`) for an app that has them. One actor that fails to compile fails the publish at step `build`. The platform caps a version at 50 actors and the CLI refuses more before compiling any; the standalone binary refuses an app with actors as it does one with functions.
`actors` is always sent, `[]` for an app with none: the platform requires the field and refuses a declare without it (422 `invalid_request`), whether or not the app has actors. One actor that fails to compile fails the publish at step `build`. The platform caps a version at 50 actors and the CLI refuses more before compiling any; the standalone binary refuses an app with actors as it does one with functions.

## Why the digest is signed into the URL

Expand All @@ -87,7 +87,9 @@ The practical consequence for this CLI: **send the checksum the server gave you,

## Resources go up raw

`collectResources` reads `entities/` and `agents/` with `readJsonFile` and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — `entities/Todo.jsonc` → `Todo`, `agents/support/triage.jsonc` → `support/triage`. That is the same name the platform derives from the same file.
`collectResources` reads `entities/`, `agents/` and `workflows/` with `readJsonFile` and sends the parsed payloads untouched, keyed by the file's path with the schema extension stripped — `entities/Todo.jsonc` → `Todo`, `agents/support/triage.jsonc` → `support/triage`, `workflows/daily-digest.jsonc` → `daily-digest`. That is the same name the platform derives from the same file.

Workflows follow the platform's own reading of that directory, which differs in two rules: **only `.jsonc`**, and **dot-files count**. A `.json` file under `workflows/` is an ordinary file, not a workflow, so the CLI skips it; `.draft.jsonc` is the workflow `.draft`, so the CLI sends it. Nested files are sent under their relative path (`sub/x`); the server refuses a key that is not path-safe. `workflows` is always sent, `{}` for an app with none: the platform requires the field and refuses a declare without it (422 `invalid_request`), whether or not the app has workflows. The directory is `workflowsDir` in `config.jsonc`, default `workflows`.

Deliberately **not** `entityResource.readAll` / `agentResource.readAll`. The platform's own extractor and validation are authoritative, and this CLI's stricter entity schema refuses real Builder apps — which is exactly why `site deploy` reads no resources at all. Re-introducing the strict parse here would reproduce that block.

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const ProjectConfigSchema = z.object({
functionsDir: z.string().optional().default("functions"),
actorsDir: z.string().optional().default("actors"),
agentsDir: z.string().optional().default("agents"),
workflowsDir: z.string().optional().default("workflows"),
agentSkillsDir: z.string().optional().default("agent-skills"),
connectorsDir: z.string().optional().default("connectors"),
authDir: z.string().optional().default("auth"),
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/core/project/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import type { ProjectWithPaths } from "@/core/project/types.js";
/** A project's resolved layout: where its build runs, and what it leaves behind. */
export interface BuildTarget {
root: string;
/** Where `entitiesDir`, `agentsDir`, `functionsDir` and `actorsDir` are
* resolved from. */
/** Where `entitiesDir`, `agentsDir`, `workflowsDir`, `functionsDir` and
* `actorsDir` are resolved from. */
configDir: string;
buildCommand: string;
outputDir: string;
entitiesDir: string;
agentsDir: string;
workflowsDir: string;
functionsDir: string;
actorsDir: string;
}
Expand Down Expand Up @@ -53,6 +54,7 @@ export async function resolveBuildTarget(
),
entitiesDir: project?.entitiesDir ?? "entities",
agentsDir: project?.agentsDir ?? "agents",
workflowsDir: project?.workflowsDir ?? "workflows",
functionsDir: project?.functionsDir ?? "functions",
actorsDir: project?.actorsDir ?? "actors",
};
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/core/version/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export async function createVersion(
actors: artifacts.actors.map(declaredActor),
entities: artifacts.entities,
agents: artifacts.agents,
workflows: artifacts.workflows,
source_commit: options.sourceCommit,
},
"declaring a version",
Expand Down
33 changes: 24 additions & 9 deletions packages/cli/src/core/version/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { createHash } from "node:crypto";
import { join, resolve } from "node:path";
import { globby } from "globby";
import pMap from "p-map";
import { CONFIG_FILE_EXTENSION_GLOB } from "@/core/consts.js";
import {
CONFIG_FILE_EXTENSION,
CONFIG_FILE_EXTENSION_GLOB,
} from "@/core/consts.js";
import { InvalidInputError } from "@/core/errors.js";
import type { BuildTarget } from "@/core/project/target.js";
import type { FullStackBuild } from "@/core/site/full-stack.js";
Expand Down Expand Up @@ -132,20 +135,26 @@ async function describeSiteWorker(
}

/**
* The app's declared entities and agents, raw — deliberately not the validated
* resource readers, whose stricter entity schema refuses real Builder apps.
* The app's declared entities, agents and workflows, raw — deliberately not the
* validated resource readers, whose stricter entity schema refuses real Builder
* apps.
*
* Keyed the way the platform names the same file: `agents/support/triage.jsonc`
* is `support/triage`.
*/
async function readRawResources(dir: string): Promise<Record<string, unknown>> {
async function readRawResources(
dir: string,
options: { extensions?: string; dot?: boolean } = {},
): Promise<Record<string, unknown>> {
if (!(await pathExists(dir))) {
return {};
}
const files = await globby(`**/*.${CONFIG_FILE_EXTENSION_GLOB}`, {
const extensions = options.extensions ?? CONFIG_FILE_EXTENSION_GLOB;
const files = await globby(`**/*.${extensions}`, {
cwd: dir,
onlyFiles: true,
followSymbolicLinks: false,
dot: options.dot ?? false,
});
const payloads: Record<string, unknown> = {};
for (const relativePath of files.sort()) {
Expand All @@ -157,13 +166,19 @@ async function readRawResources(dir: string): Promise<Record<string, unknown>> {

export async function collectResources(
configDir: string,
dirs: { entitiesDir: string; agentsDir: string },
): Promise<Pick<ArtifactSet, "entities" | "agents">> {
const [entities, agents] = await Promise.all([
dirs: { entitiesDir: string; agentsDir: string; workflowsDir: string },
): Promise<Pick<ArtifactSet, "entities" | "agents" | "workflows">> {
const [entities, agents, workflows] = await Promise.all([
readRawResources(join(configDir, dirs.entitiesDir)),
readRawResources(join(configDir, dirs.agentsDir)),
// As the platform reads the same directory: a `.json` file there is an
// ordinary file, not a workflow, while a dot-file is one (`.draft`).
readRawResources(join(configDir, dirs.workflowsDir), {
extensions: CONFIG_FILE_EXTENSION,
dot: true,
}),
]);
return { entities, agents };
return { entities, agents, workflows };
}

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/core/version/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,15 @@ export interface ArtifactSet {
* platform this producer cannot describe functions, and it refuses that for
* an app that has them. */
backendBundles: BackendBundleArtifact[];
/** Always sent, empty when the app has none, for the same reason. */
/** Always sent, empty when the app has none: the platform refuses a declare
* without it (422), for any app. */
actors: ActorArtifact[];
/** Raw payloads by name. The server normalizes and hashes them. */
entities: Record<string, unknown>;
agents: Record<string, unknown>;
/** Always sent, `{}` when the app has none: the platform refuses a declare
* without it (422), for any app. */
workflows: Record<string, unknown>;
}

export interface CreateVersionProgress {
Expand Down
38 changes: 36 additions & 2 deletions packages/cli/tests/cli/publish.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ describe("publish command", () => {
});
});

it("sends the app's workflows raw, keyed by their path under workflows/", async () => {
t.givenEnv({ BASE44_VERSIONS_API: "1" });
await t.givenLoggedInWithProject(fixture("publishable"));
mockPublishApi();

const result = await t.run("publish", "--no-build");

t.expectResult(result).toSucceed();
// `notes.json` beside it is not a workflow: the platform takes `.jsonc` only.
expect(t.api.versionDeclareRequests[0]).toMatchObject({
workflows: {
"daily-digest": {
name: "Daily digest",
trigger: { type: "schedule", cron: "0 9 * * *" },
steps: [],
},
},
});
expect(
Object.keys(
(t.api.versionDeclareRequests[0] as { workflows: object }).workflows,
),
).toEqual(["daily-digest"]);
});

it("declares an app with no functions as having none", async () => {
// Not an absent field: that is what an older CLI sends, and the platform
// refuses it for an app whose functions it would then remove.
Expand Down Expand Up @@ -402,8 +427,8 @@ describe.skipIf(process.env.CLI_TEST_RUNNER === "binary")(
});

it("declares an app with no actors as having none", async () => {
// Not an absent field: that is what an older CLI sends, and the platform
// refuses it for an app whose actors it would then drop.
// Not an absent field: the platform refuses a declare without `actors`,
// for any app.
t.givenEnv({ BASE44_VERSIONS_API: "1" });
await t.givenLoggedInWithProject(fixture("publishable"));
t.api
Expand Down Expand Up @@ -606,6 +631,15 @@ describe("publish command, for an app with a server of its own", () => {
});
});

it("declares an app with no workflows as having none", async () => {
// Not an absent field: the platform refuses a declare without `workflows`,
// for any app.
const result = await publish();

t.expectResult(result).toSucceed();
expect(t.api.versionDeclareRequests[0]).toMatchObject({ workflows: {} });
});

it("takes the assets from the Worker's own directory", async () => {
// Collecting the project's build output instead would ask the platform to
// serve the Worker's files from S3, past every route it owns.
Expand Down
1 change: 1 addition & 0 deletions packages/cli/tests/core/project-target.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("resolveBuildTarget", () => {
outputDir: resolve(root, "dist"),
entitiesDir: "entities",
agentsDir: "agents",
workflowsDir: "workflows",
functionsDir: "functions",
actorsDir: "actors",
});
Expand Down
86 changes: 84 additions & 2 deletions packages/cli/tests/core/version-artifacts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { InvalidInputError } from "@/core/errors.js";
import { ConfigInvalidError, InvalidInputError } from "@/core/errors.js";
import { hashAsset } from "@/core/site/manifest.js";
import {
collectArtifacts,
Expand Down Expand Up @@ -126,7 +126,11 @@ describe("collectResources", () => {
await rm(configDir, { recursive: true, force: true });
});

const dirs = { entitiesDir: "entities", agentsDir: "agents" };
const dirs = {
entitiesDir: "entities",
agentsDir: "agents",
workflowsDir: "workflows",
};

it("keys a payload by its path with the schema extension stripped", async () => {
// The same name the platform derives from the same file, so a version made
Expand Down Expand Up @@ -166,8 +170,85 @@ describe("collectResources", () => {
expect(await collectResources(configDir, dirs)).toEqual({
entities: {},
agents: {},
workflows: {},
});
});

it("keys a workflow by its path under the workflows directory", async () => {
// The platform's `workflow_files_key`: `workflows/daily-digest.jsonc` is
// `daily-digest`, and a nested file keeps its relative path.
await mkdir(join(configDir, "workflows", "sub"), { recursive: true });
await writeFile(
join(configDir, "workflows", "daily-digest.jsonc"),
'{ "name": "Daily digest", "steps": [] }',
);
await writeFile(join(configDir, "workflows", "sub", "x.jsonc"), "{}");

const { workflows } = await collectResources(configDir, dirs);

expect(workflows).toEqual({
"daily-digest": { name: "Daily digest", steps: [] },
"sub/x": {},
});
});

it("reads a workflow as JSONC and sends it raw", async () => {
await mkdir(join(configDir, "workflows"));
await writeFile(
join(configDir, "workflows", "w.jsonc"),
'{\n // a comment\n "anything": { "goes": [1, 2,] },\n}',
);

const { workflows } = await collectResources(configDir, dirs);

expect(workflows).toEqual({ w: { anything: { goes: [1, 2] } } });
});

it("takes only .jsonc files as workflows", async () => {
// Unlike entities and agents: to the platform a `.json` file in the
// workflows directory is an ordinary file, not a workflow.
await mkdir(join(configDir, "workflows"));
await writeFile(join(configDir, "workflows", "real.jsonc"), "{}");
await writeFile(join(configDir, "workflows", "notes.json"), "{}");

const { workflows } = await collectResources(configDir, dirs);

expect(workflows).toEqual({ real: {} });
});

it("takes a hidden .jsonc file as a workflow", async () => {
// The platform counts `.draft.jsonc` as the workflow `.draft`; skipping it
// here would leave the version without one the app has.
await mkdir(join(configDir, "workflows"));
await writeFile(join(configDir, "workflows", ".draft.jsonc"), "{}");

const { workflows } = await collectResources(configDir, dirs);

expect(workflows).toEqual({ ".draft": {} });
});

it("fails on a workflow that does not parse, naming the file", async () => {
await mkdir(join(configDir, "workflows"));
const broken = join(configDir, "workflows", "broken.jsonc");
await writeFile(broken, "{ not json");

const failure = collectResources(configDir, dirs);

await expect(failure).rejects.toBeInstanceOf(ConfigInvalidError);
await expect(failure).rejects.toThrow(broken);
});

it("reads workflows from the configured directory", async () => {
await mkdir(join(configDir, "flows"));
await writeFile(join(configDir, "flows", "w.jsonc"), "{}");

const { workflows } = await collectResources(configDir, {
...dirs,
workflowsDir: "flows",
});

expect(workflows).toEqual({ w: {} });
});
});

describe("collectSiteWorker", () => {
Expand Down Expand Up @@ -378,6 +459,7 @@ describe("collectArtifacts", () => {
outputDir: join(projectRoot, "dist", "client"),
entitiesDir: "entities",
agentsDir: "agents",
workflowsDir: "workflows",
functionsDir: "functions",
actorsDir: "actors",
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
// Sent raw: the platform validates workflows, not the CLI.
"name": "Daily digest",
"trigger": { "type": "schedule", "cron": "0 9 * * *" },
"steps": [],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "not": "a workflow" }
Loading