Skip to content

Commit f850e51

Browse files
yyx990803claude
andcommitted
feat: versioned deployments — rollback CLI, manifest v3, worker-side migrations
- Add `void project rollback [deployId]` command with interactive selection - Add migration handler runtime for worker-side D1 migrations (v3 flow) - Update deploy manifest: migrations as `{ dialect, pending }`, hyperdrive as `string[]` - Add `--deployment` filter to `void project logs` - Show live/retained/evicted status in `void project status` - Inject `__void/migrate` route into compiled worker entry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c659ef5 commit f850e51

17 files changed

Lines changed: 433 additions & 45 deletions

File tree

‎docs/reference/cli.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ outline: deep
2222
| `void auth login` | Authenticate with Void |
2323
| `void project link` | Link directory to a project |
2424
| `void project logs` | Show runtime logs from deployed project |
25+
| `void project rollback` | Roll back to a previous deployment |
2526
| `void init` | Setup wizard (tsconfig, agents, CI) |
2627

2728
## Binary Invocation
@@ -101,7 +102,7 @@ List all your projects (slug, mode, URL).
101102
### `void project logs`
102103

103104
```
104-
void project logs [--filter <text>] [--range <duration>]
105+
void project logs [--filter <text>] [--range <duration>] [--deployment <id>]
105106
```
106107

107108
Show runtime logs from the deployed project. Uses the linked project from `.void/project.json`.
@@ -110,9 +111,23 @@ Show runtime logs from the deployed project. Uses the linked project from `.void
110111
| -------------------- | --------------------------------------------------------------------------------------------------------------- | ------- |
111112
| `--range <duration>` | How far back to look. Format: `<number><unit>` (m/h/d). Max 7d. | `1h` |
112113
| `--filter <text>` | Case-insensitive text match against log/exception messages. Shows full request entry when any log line matches. | — |
114+
| `--deployment <id>` | Filter logs to a specific deployment ID. | — |
113115

114116
Output shows one line per request (`HH:MM:SS METHOD URL STATUS`) with indented console log and exception lines beneath. Errors and exceptions are colored red, warnings yellow.
115117

118+
### `void project rollback [deployId]`
119+
120+
```
121+
void project rollback [deployId]
122+
```
123+
124+
Roll back to a previous deployment. Traffic instantly switches to the target deployment's worker script via KV routing update.
125+
126+
- If `[deployId]` is omitted, shows an interactive select menu of retained deployments
127+
- If the target deployment has fewer applied migrations than the current one, a warning is shown listing the migration diff before confirmation
128+
129+
Only **retained** deployments can be rolled back to. The number of retained deployments depends on your plan (free: 1, solo: 5, pro: 25, unlimited for sponsored/custom).
130+
116131
### `void project delete [name]`
117132

118133
Permanently delete a project and all its resources (databases, KV namespaces, R2 buckets, deployments). Requires typing the project slug to confirm.

‎packages/void/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@
9191
"types": "./dist/runtime/remote/binding-handler.d.mts",
9292
"import": "./dist/runtime/remote/binding-handler.mjs"
9393
},
94+
"./runtime/migration-handler": {
95+
"types": "./dist/runtime/migration-handler.d.mts",
96+
"import": "./dist/runtime/migration-handler.mjs"
97+
},
9498
"./pages": {
9599
"types": "./dist/pages/index.d.mts",
96100
"import": "./dist/pages/index.mjs"

‎packages/void/src/cli/cli.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ function printHelp() {
4444
helpLine("void project link [name]", "Link directory to a project"),
4545
helpLine("void project list", "List your projects"),
4646
helpLine("void project logs [--filter] [--range]", "Show runtime logs from deployed project"),
47+
helpLine("void project rollback [deployId]", "Roll back to a previous deployment"),
4748
helpLine("void project delete [name]", "Delete a project and all its resources"),
4849
"",
4950
categoryTitle("secrets"),
@@ -238,7 +239,7 @@ if (command === "staging") {
238239
},
239240
{
240241
name: "project",
241-
hint: "status | link | list | logs | delete",
242+
hint: "status | link | list | logs | rollback | delete",
242243
description: "Manage projects and deployments",
243244
},
244245
{ name: "secret", hint: "list | put | delete | sync", description: "Manage project secrets" },

‎packages/void/src/cli/client.ts‎

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,17 @@ export interface DeploymentResponse {
2929
commit: string | null;
3030
manifest: string | null;
3131
error_message: string | null;
32+
retained: boolean;
33+
applied_migrations: string | null;
3234
created_at: string;
3335
deployed_at: string | null;
3436
}
3537

38+
export interface DeploymentListResponse {
39+
deployments: DeploymentResponse[];
40+
activeDeploymentId: string | null;
41+
}
42+
3643
export interface SecretResponse {
3744
ok: boolean;
3845
name: string;
@@ -105,7 +112,7 @@ export class PlatformClient {
105112
return res.json();
106113
}
107114

108-
async listDeployments(projectId: string): Promise<DeploymentResponse[]> {
115+
async listDeployments(projectId: string): Promise<DeploymentListResponse> {
109116
const res = await fetch(`${this.baseUrl}/projects/${projectId}/deployments`, {
110117
headers: {
111118
Authorization: `Bearer ${this.token}`,
@@ -120,6 +127,27 @@ export class PlatformClient {
120127
return res.json();
121128
}
122129

130+
async rollback(
131+
projectId: string,
132+
deploymentId: string,
133+
): Promise<{ ok: boolean; deploymentId?: string; migrationDiff?: string[]; error?: string }> {
134+
const res = await fetch(`${this.baseUrl}/projects/${projectId}/rollback`, {
135+
method: "POST",
136+
headers: {
137+
"Content-Type": "application/json",
138+
Authorization: `Bearer ${this.token}`,
139+
},
140+
body: JSON.stringify({ deploymentId }),
141+
});
142+
143+
if (!res.ok) {
144+
const body = await res.text();
145+
throw new Error(`Rollback failed: ${res.status} ${body}`);
146+
}
147+
148+
return res.json();
149+
}
150+
123151
async createProject(slug: string): Promise<CreateProjectResponse> {
124152
const res = await fetch(`${this.baseUrl}/projects`, {
125153
method: "POST",
@@ -351,15 +379,23 @@ export class PlatformClient {
351379
return res.json();
352380
}
353381

354-
async getLogs(projectId: string, since: string, limit = 200): Promise<LogEntry[]> {
355-
const res = await fetch(
356-
`${this.baseUrl}/projects/${projectId}/logs?since=${encodeURIComponent(since)}&limit=${limit}`,
357-
{
358-
headers: {
359-
Authorization: `Bearer ${this.token}`,
360-
},
382+
async getLogs(
383+
projectId: string,
384+
since: string,
385+
limit = 200,
386+
deploymentId?: string,
387+
): Promise<LogEntry[]> {
388+
const params = new URLSearchParams({
389+
since,
390+
limit: String(limit),
391+
});
392+
if (deploymentId) params.set("deploymentId", deploymentId);
393+
394+
const res = await fetch(`${this.baseUrl}/projects/${projectId}/logs?${params}`, {
395+
headers: {
396+
Authorization: `Bearer ${this.token}`,
361397
},
362-
);
398+
});
363399

364400
if (!res.ok) {
365401
const body = await res.text();

‎packages/void/src/cli/deploy.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,11 @@ async function streamDeploy(
213213
case "provisioning":
214214
s.message(`Provisioning ${event.bindings.join(", ")}`);
215215
break;
216+
case "migration_start":
217+
s.message(`Running ${event.pending} migration${event.pending > 1 ? "s" : ""}...`);
218+
break;
216219
case "migration":
217-
s.message(`Running migration ${event.index}/${event.total}: ${event.name}`);
220+
s.message(`Applied migration: ${event.name} (${event.durationMs}ms)`);
218221
break;
219222
case "worker_upload":
220223
s.message("Uploading worker...");

‎packages/void/src/cli/list.ts‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { intro, outro, log } from "@clack/prompts";
22
import { getToken } from "./auth";
3-
import { PlatformClient, type DeploymentResponse } from "./client";
3+
import { PlatformClient, type DeploymentResponse, type DeploymentListResponse } from "./client";
44
import { readProjectConfig } from "./project";
55
import { resolveProjectBySlug } from "./resolve-project";
66
import { cliTitle, dim, pc, tablePrefix } from "./output";
@@ -47,7 +47,7 @@ async function showDeployments(
4747
projectId: string,
4848
slug: string,
4949
): Promise<void> {
50-
const deployments = await client.listDeployments(projectId);
50+
const { deployments, activeDeploymentId } = await client.listDeployments(projectId);
5151

5252
if (deployments.length === 0) {
5353
outro(`No deployments for ${slug}.`);
@@ -59,7 +59,7 @@ async function showDeployments(
5959
log.error(`Latest deploy failed: ${latest.error_message}`);
6060
}
6161

62-
const rows = deployments.map((d) => formatDeploymentRow(d));
62+
const rows = deployments.map((d) => formatDeploymentRow(d, activeDeploymentId));
6363

6464
const idWidth = Math.max("id".length, ...rows.map((r) => r.id.length));
6565
const typeWidth = Math.max("type".length, ...rows.map((r) => r.appType.length));
@@ -93,7 +93,33 @@ interface DeploymentRow {
9393
deployed: string;
9494
}
9595

96-
function formatDeploymentRow(d: DeploymentResponse): DeploymentRow {
96+
function computeDisplayStatus(d: DeploymentResponse, activeDeploymentId: string | null): string {
97+
if (d.status === "failed") return "failed";
98+
if (d.status !== "live") return d.status;
99+
if (d.id === activeDeploymentId) return "live";
100+
if (d.retained) return "retained";
101+
return "evicted";
102+
}
103+
104+
function colorStatus(status: string): string {
105+
switch (status) {
106+
case "live":
107+
return pc.bold(pc.green(status));
108+
case "retained":
109+
return dim(pc.green(status));
110+
case "evicted":
111+
return dim(status);
112+
case "failed":
113+
return pc.red(status);
114+
default:
115+
return status;
116+
}
117+
}
118+
119+
function formatDeploymentRow(
120+
d: DeploymentResponse,
121+
activeDeploymentId: string | null,
122+
): DeploymentRow {
97123
let manifest: {
98124
type?: string;
99125
static?: string;
@@ -113,13 +139,8 @@ function formatDeploymentRow(d: DeploymentResponse): DeploymentRow {
113139
const source = d.source ?? "-";
114140
const commit = d.commit ? d.commit.slice(0, 7) : "-";
115141

116-
const statusRaw = d.status;
117-
let statusColored = statusRaw;
118-
if (d.status === "live") {
119-
statusColored = pc.green(statusRaw);
120-
} else if (d.status === "failed") {
121-
statusColored = pc.red(statusRaw);
122-
}
142+
const statusRaw = computeDisplayStatus(d, activeDeploymentId);
143+
const statusColored = colorStatus(statusRaw);
123144

124145
const deployed = d.deployed_at ? formatRelativeTime(d.deployed_at) : "-";
125146

‎packages/void/src/cli/logs.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { PlatformClient, type LogEntry } from "./client";
44
import { readProjectConfig } from "./project";
55
import { cliTitle, dim, pc } from "./output";
66

7-
export async function runLogsCommand(root: string, filter: string, range: string): Promise<void> {
7+
export async function runLogsCommand(
8+
root: string,
9+
filter: string,
10+
range: string,
11+
deployment?: string,
12+
): Promise<void> {
813
console.log();
914
intro(cliTitle("project logs"));
1015

@@ -29,7 +34,7 @@ export async function runLogsCommand(root: string, filter: string, range: string
2934

3035
let entries: LogEntry[];
3136
try {
32-
entries = await client.getLogs(linked.projectId, range);
37+
entries = await client.getLogs(linked.projectId, range, 200, deployment || undefined);
3338
} catch (err) {
3439
log.error(err instanceof Error ? err.message : String(err));
3540
process.exit(1);

‎packages/void/src/cli/package.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export function packageBuild(
112112
const formData = new FormData();
113113

114114
// Build manifest from inferred bindings
115-
const manifest: DeployManifest = { version: 2, bindings: {} };
115+
const manifest: DeployManifest = { version: 3, bindings: {} };
116116
if (bindings.needsD1) manifest.bindings.d1 = [options?.bindingNames?.d1 ?? "DB"];
117117
if (bindings.needsKV) manifest.bindings.kv = [options?.bindingNames?.kv ?? "KV"];
118118
if (bindings.needsR2) manifest.bindings.r2 = [options?.bindingNames?.r2 ?? "STORAGE"];
@@ -124,10 +124,10 @@ export function packageBuild(
124124

125125
// Add migration metadata to manifest + SQL content as FormData entries
126126
if (migrations && migrations.length > 0) {
127-
manifest.migrations = migrations.map((m) => ({
128-
name: m.name,
129-
destructive: m.destructive,
130-
}));
127+
manifest.migrations = {
128+
dialect: "sqlite",
129+
pending: migrations.length,
130+
};
131131
for (const m of migrations) {
132132
formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name);
133133
}
@@ -234,7 +234,7 @@ export function packageStaticBuild(
234234
const formData = new FormData();
235235

236236
const manifest: DeployManifest = {
237-
version: 2,
237+
version: 3,
238238
bindings: {},
239239
type: appType,
240240
assetConfig: {
@@ -300,7 +300,7 @@ export function packageFrameworkBuild(opts: {
300300
const po = opts.packageOptions;
301301

302302
// Build manifest
303-
const manifest: DeployManifest = { version: 2, bindings: {} };
303+
const manifest: DeployManifest = { version: 3, bindings: {} };
304304
if (opts.bindings.needsD1) manifest.bindings.d1 = [po?.bindingNames?.d1 ?? "DB"];
305305
if (opts.bindings.needsKV) manifest.bindings.kv = [po?.bindingNames?.kv ?? "KV"];
306306
if (opts.bindings.needsR2) manifest.bindings.r2 = [po?.bindingNames?.r2 ?? "STORAGE"];
@@ -311,10 +311,10 @@ export function packageFrameworkBuild(opts: {
311311
}
312312

313313
if (opts.migrations && opts.migrations.length > 0) {
314-
manifest.migrations = opts.migrations.map((m) => ({
315-
name: m.name,
316-
destructive: m.destructive,
317-
}));
314+
manifest.migrations = {
315+
dialect: "sqlite",
316+
pending: opts.migrations.length,
317+
};
318318
for (const m of opts.migrations) {
319319
formData.append(`migration:${m.name}`, new Blob([m.sql]), m.name);
320320
}

‎packages/void/src/cli/project-args.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const projectSubcommands: SubcommandDef[] = [
55
{ name: "link", arg: "[name]", description: "Link directory to a project" },
66
{ name: "list", description: "List your projects" },
77
{ name: "logs", description: "Show runtime logs from the deployed project" },
8+
{ name: "rollback", arg: "[deployId]", description: "Roll back to a previous deployment" },
89
{
910
name: "delete",
1011
arg: "[name]",
@@ -16,7 +17,8 @@ export type ProjectArgs =
1617
| { subcommand: "status"; name: string }
1718
| { subcommand: "link"; name: string }
1819
| { subcommand: "list" }
19-
| { subcommand: "logs"; filter: string; range: string }
20+
| { subcommand: "logs"; filter: string; range: string; deployment: string }
21+
| { subcommand: "rollback"; deployId: string }
2022
| { subcommand: "delete"; name: string; force: boolean };
2123

2224
export function parseProjectArgs(args: string[]): ProjectArgs {
@@ -48,16 +50,27 @@ export function parseProjectArgs(args: string[]): ProjectArgs {
4850
if (subcommand === "logs") {
4951
let filter = "";
5052
let range = "1h";
53+
let deployment = "";
5154
for (let i = 0; i < rest.length; i++) {
5255
if (rest[i] === "--filter" && rest[i + 1]) {
5356
filter = rest[++i];
5457
} else if (rest[i] === "--range" && rest[i + 1]) {
5558
range = rest[++i];
59+
} else if (rest[i] === "--deployment" && rest[i + 1]) {
60+
deployment = rest[++i];
5661
} else {
5762
throw new Error(`Error: Unexpected argument: ${rest[i]}`);
5863
}
5964
}
60-
return { subcommand, filter, range };
65+
return { subcommand, filter, range, deployment };
66+
}
67+
68+
if (subcommand === "rollback") {
69+
const [deployId, extra] = rest;
70+
if (extra) {
71+
throw new Error(`Error: Unexpected argument: ${extra}`);
72+
}
73+
return { subcommand, deployId: deployId ?? "" };
6174
}
6275

6376
if (subcommand === "delete") {
@@ -77,5 +90,5 @@ export function parseProjectArgs(args: string[]): ProjectArgs {
7790
return { subcommand, name, force };
7891
}
7992

80-
throw new Error("Error: Usage: void project <status|link|list|logs|delete>");
93+
throw new Error("Error: Usage: void project <status|link|list|logs|rollback|delete>");
8194
}

‎packages/void/src/cli/project-cmd.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ export async function runProjectCommand(root: string, args: ProjectArgs): Promis
1515
}
1616
if (args.subcommand === "logs") {
1717
const { runLogsCommand } = await import("./logs");
18-
return runLogsCommand(root, args.filter, args.range);
18+
return runLogsCommand(root, args.filter, args.range, args.deployment || undefined);
19+
}
20+
if (args.subcommand === "rollback") {
21+
const { runRollbackCommand } = await import("./rollback");
22+
return runRollbackCommand(root, args.deployId || undefined);
1923
}
2024
if (args.subcommand === "delete") {
2125
const { runDeleteCommand } = await import("./delete");

0 commit comments

Comments
 (0)