Advertisement

← vibesdk

worker/api/controllers/appDatabase/controller.ts

6.2 KB Download file
/**
 * AppDatabaseController
 *
 * Read-only inspector endpoints for the space-generated app's Durable
 * Object storage. Powers the "DB" tab in the chat UI.
 *
 * The LLM-generated worker exports `class App extends DurableObject` from
 * its main module. SpaceDO hosts that class as a Facet (`app:<branch>`)
 * via the Worker Loader and exposes its SQLite storage through the
 * inspector RPC methods (`listAppTables`, `queryAppTable`,
 * `wipeAppDatabase`). The inspector methods themselves are injected into
 * a subclass of the user's App at load time (see the space package's
 * `inspector-wrapper.ts`), so the LLM does not have to write any
 * boilerplate.
 *
 * All endpoints accept an `:agentId` path param. A session's space is
 * always its agent id (one space per session), so the SpaceDO stub is
 * resolved by `SPACE_DO.idFromName(agentId)`.
 */
import { BaseController } from '../baseController';
import { createLogger } from '../../../logger';
import { getAgentState } from '../../../agents';
import { RouteContext } from '../../types/route-context';
import { ApiResponse, ControllerResponse } from '../types';
import type {
	ListAppTablesResponse,
	QueryAppTableResponse,
	WipeAppDatabaseResponse,
} from './types';
import type {
	AppDatabaseReadResult,
	AppDatabaseTable,
} from '@space-do/space';

interface SpaceDbStub extends DurableObjectStub {
	listAppTables(branch: string): Promise<AppDatabaseTable[]>;
	queryAppTable(
		branch: string,
		table: string,
		opts: {
			limit?: number;
			offset?: number;
			orderBy?: string;
			orderDir?: 'asc' | 'desc';
		},
	): Promise<AppDatabaseReadResult>;
	wipeAppDatabase(branch: string): Promise<{ ok: true }>;
}

interface AgentStateLite {
	currentBranch?: string;
}

const MAX_PAGE_SIZE = 200;
const DEFAULT_PAGE_SIZE = 50;

export class AppDatabaseController extends BaseController {
	static logger = createLogger('AppDatabaseController');

	private static async resolveSpaceContext(
		env: Env,
		agentId: string,
		branchOverride?: string,
	): Promise<{ stub: SpaceDbStub; spaceName: string; branch: string }> {
		// One space per session: the space is always the agent id.
		const spaceName = agentId;
		let branch = branchOverride ?? 'main';

		try {
			const state = (await getAgentState(env, agentId)) as unknown as AgentStateLite;
			if (!branchOverride && state?.currentBranch) branch = state.currentBranch;
		} catch (e) {
			this.logger.debug(
				`Could not resolve agent state for ${agentId}; falling back to defaults`,
				{ error: e instanceof Error ? e.message : String(e) },
			);
		}

		const ns = (env as unknown as { SPACE_DO?: DurableObjectNamespace }).SPACE_DO;
		if (!ns) {
			throw new Error('SPACE_DO binding is not configured');
		}
		const stub = ns.get(ns.idFromName(spaceName)) as unknown as SpaceDbStub;
		return { stub, spaceName, branch };
	}

	static async listTables(
		request: Request,
		env: Env,
		_: ExecutionContext,
		context: RouteContext,
	): Promise<ControllerResponse<ApiResponse<ListAppTablesResponse>>> {
		try {
			const agentId = context.pathParams.agentId;
			if (!agentId) {
				return this.createErrorResponse<ListAppTablesResponse>(
					'Missing agent ID',
					400,
				);
			}
			const params = this.parseQueryParams(request);
			const { stub, branch } = await this.resolveSpaceContext(
				env,
				agentId,
				params.get('branch') ?? undefined,
			);
			const tables = await stub.listAppTables(branch);
			return this.createSuccessResponse<ListAppTablesResponse>({ branch, tables });
		} catch (e) {
			return this.handleError(e, 'list app tables') as ControllerResponse<
				ApiResponse<ListAppTablesResponse>
			>;
		}
	}

	static async queryTable(
		request: Request,
		env: Env,
		_: ExecutionContext,
		context: RouteContext,
	): Promise<ControllerResponse<ApiResponse<QueryAppTableResponse>>> {
		try {
			const agentId = context.pathParams.agentId;
			if (!agentId) {
				return this.createErrorResponse<QueryAppTableResponse>(
					'Missing agent ID',
					400,
				);
			}
			const params = this.parseQueryParams(request);
			const table = params.get('table');
			if (!table) {
				return this.createErrorResponse<QueryAppTableResponse>(
					'Missing table parameter',
					400,
				);
			}
			const rawLimit = Number(params.get('limit') ?? DEFAULT_PAGE_SIZE);
			const rawOffset = Number(params.get('offset') ?? 0);
			const limit = Math.max(
				1,
				Math.min(MAX_PAGE_SIZE, Number.isFinite(rawLimit) ? rawLimit : DEFAULT_PAGE_SIZE),
			);
			const offset = Math.max(
				0,
				Number.isFinite(rawOffset) ? rawOffset : 0,
			);
			const orderBy = params.get('orderBy') || undefined;
			const orderDirRaw = params.get('orderDir');
			const orderDir: 'asc' | 'desc' | undefined =
				orderDirRaw === 'asc' || orderDirRaw === 'desc' ? orderDirRaw : undefined;

			const { stub, branch } = await this.resolveSpaceContext(
				env,
				agentId,
				params.get('branch') ?? undefined,
			);
			const result = await stub.queryAppTable(branch, table, {
				limit,
				offset,
				orderBy,
				orderDir,
			});
			return this.createSuccessResponse<QueryAppTableResponse>({
				branch,
				table,
				columns: result.columns,
				rows: result.rows,
				totalCount: result.totalCount,
				limit,
				offset,
			});
		} catch (e) {
			return this.handleError(e, 'query app table') as ControllerResponse<
				ApiResponse<QueryAppTableResponse>
			>;
		}
	}

	static async wipe(
		request: Request,
		env: Env,
		_: ExecutionContext,
		context: RouteContext,
	): Promise<ControllerResponse<ApiResponse<WipeAppDatabaseResponse>>> {
		try {
			const agentId = context.pathParams.agentId;
			if (!agentId) {
				return this.createErrorResponse<WipeAppDatabaseResponse>(
					'Missing agent ID',
					400,
				);
			}
			const bodyResult = await this.parseJsonBody<{ branch?: string }>(request);
			const body = bodyResult.success ? (bodyResult.data ?? {}) : {};
			const { stub, branch } = await this.resolveSpaceContext(env, agentId, body.branch);
			await stub.wipeAppDatabase(branch);
			return this.createSuccessResponse<WipeAppDatabaseResponse>({ ok: true });
		} catch (e) {
			return this.handleError(e, 'wipe app database') as ControllerResponse<
				ApiResponse<WipeAppDatabaseResponse>
			>;
		}
	}
}