Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/vast-moles-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@vercel/sandbox": minor
"sandbox": minor
---

Support snapshot tree pagination
4 changes: 3 additions & 1 deletion packages/sandbox/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const sandboxClient: Pick<typeof Sandbox, "get" | "list" | "create"> = {

export const snapshotClient: Pick<
typeof Snapshot,
"get" | "list" | "fromSandbox"
"get" | "list" | "fromSandbox" | "tree"
> = {
list: (params) =>
withErrorHandling(() =>
Expand All @@ -39,6 +39,8 @@ export const snapshotClient: Pick<
withErrorHandling(
() => Snapshot.fromSandbox(name, { fetch: fetchWithUserAgent, ...opts }),
),
tree: (params) =>
withErrorHandling(() => Snapshot.tree({ fetch: fetchWithUserAgent, ...params })),
};

const fetchWithUserAgent: typeof globalThis.fetch = (input, init) => {
Expand Down
178 changes: 177 additions & 1 deletion packages/sandbox/src/commands/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import ora from "ora";
import { scope } from "../args/scope";
import { sandboxName } from "../args/sandbox-name";
import { snapshotId } from "../args/snapshot-id";
import { snapshotClient } from "../client";
import { sandboxClient, snapshotClient } from "../client";
import { acquireRelease } from "../util/disposables";
import { formatBytes, formatNextCursorHint, table, timeAgo } from "../util/output";
import { renderSnapshotTree } from "../util/snapshot-tree";

const list = cmd.command({
name: "list",
Expand Down Expand Up @@ -172,12 +173,187 @@ const remove = cmd.command({
},
});

const tree = cmd.command({
name: "tree",
description: "Show the snapshot ancestry tree for a sandbox.",
args: {
scope,
sandboxName: cmd.positional({
type: sandboxName,
description: "Sandbox name",
}),
sortOrder: cmd.option({
long: "sort-order",
description:
"Sort order. Options: asc, desc (default). 'desc' walks ancestors, 'asc' walks descendants. Requires --cursor.",
type: cmd.optional(cmd.oneOf(["asc", "desc"] as const)),
}),
limit: cmd.option({
long: "limit",
description:
"Maximum number of snapshots per page (1–10, default 10).",
type: cmd.optional(cmd.number),
}),
cursor: cmd.option({
long: "cursor",
description:
"Pagination cursor from a previous 'More ancestors' or 'More descendants' hint.",
type: cmd.optional(cmd.string),
}),
},
async handler({
scope: { token, team, project },
sandboxName: name,
sortOrder,
limit,
cursor,
}) {
if (limit !== undefined && (limit < 1 || limit > 10)) {
console.error(
chalk.red("Error: --limit must be between 1 and 10."),
);
process.exitCode = 1;
return;
}

if (sortOrder !== undefined && !cursor) {
console.error(
chalk.red("Error: --sort-order requires --cursor."),
);
process.exitCode = 1;
return;
}

const pageLimit = limit ?? 10;

// Paginated single-direction branch.
if (cursor) {
const effectiveSortOrder = sortOrder ?? "desc";
const page = await (async () => {
using _spinner = acquireRelease(
() => ora("Fetching snapshot tree...").start(),
(s) => s.stop(),
);
return snapshotClient.tree({
snapshotId: cursor,
sortOrder: effectiveSortOrder,
limit: pageLimit,
token,
teamId: team,
projectId: project,
});
})();

const ancestors =
effectiveSortOrder === "desc"
? page
: { snapshots: [], pagination: { count: 0, next: null } };
const descendants =
effectiveSortOrder === "asc"
? page
: { snapshots: [], pagination: { count: 0, next: null } };

console.log(
renderSnapshotTree({
hideCurrent: true,
ancestors,
descendants,
}),
);

if (page.pagination.next !== null) {
console.log(formatNextCursorHint(page.pagination.next));
}
return;
}

// Default: bidirectional view anchored on the sandbox's current snapshot.
const result = await (async () => {
using _spinner = acquireRelease(
() => ora("Fetching snapshot tree...").start(),
(s) => s.stop(),
);

const sandbox = await sandboxClient.get({
name,
token,
teamId: team,
projectId: project,
});

const currentSnapshotId = sandbox.currentSnapshotId;
if (!currentSnapshotId) {
return null;
}

const [currentSnap, ancestors, descendants] = await Promise.all([
snapshotClient.get({
snapshotId: currentSnapshotId,
token,
teamId: team,
projectId: project,
}),
snapshotClient.tree({
snapshotId: currentSnapshotId,
sortOrder: "desc",
limit: pageLimit,
token,
teamId: team,
projectId: project,
}),
snapshotClient.tree({
snapshotId: currentSnapshotId,
sortOrder: "asc",
limit: pageLimit,
token,
teamId: team,
projectId: project,
}),
]);

return {
currentSnap,
currentSnapshotId,
ancestors,
descendants,
};
})();

if (!result) {
console.log(chalk.yellow("No snapshots found for this sandbox."));
return;
}

console.log(
renderSnapshotTree({
currentSnapshotId: result.currentSnapshotId,
currentSnapshotExpiresAt: result.currentSnap.expiresAt?.getTime(),
ancestors: result.ancestors,
descendants: result.descendants,
}),
);

const limitArg = limit !== undefined ? ` --limit ${limit}` : "";
if (result.ancestors.pagination.next !== null) {
console.log(
`\nMore ancestors: sandbox snapshots tree ${name} --sort-order desc${limitArg} --cursor ${result.ancestors.pagination.next}`,
);
}
if (result.descendants.pagination.next !== null) {
console.log(
`More descendants: sandbox snapshots tree ${name} --sort-order asc${limitArg} --cursor ${result.descendants.pagination.next}`,
);
}
},
});

export const snapshots = subcommands({
name: "snapshots",
description: "Manage sandbox snapshots",
cmds: {
list,
get,
tree,
delete: remove,
},
});
Expand Down
Loading
Loading