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
126 changes: 126 additions & 0 deletions packages/control-plane/src/db/repo-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@ const QUERY_PATTERNS = {
SELECT_STATUS:
/^SELECT \* FROM repo_images WHERE repo_owner = \? AND repo_name = \? ORDER BY created_at DESC LIMIT 10$/,
SELECT_ALL_STATUS: /^SELECT \* FROM repo_images ORDER BY created_at DESC LIMIT 100$/,
SELECT_STORED_FOR_REPO:
/^SELECT id, provider_image_id FROM repo_images WHERE repo_owner = \? AND repo_name = \? AND status != 'building'$/,
UPDATE_STALE:
/^UPDATE repo_images SET status = 'failed', error_message = \? WHERE status = 'building' AND created_at < \?$/,
DELETE_OLD_FAILED: /^DELETE FROM repo_images WHERE status = 'failed' AND created_at < \?$/,
DELETE_STORED_FOR_REPO_BY_IDS: /^DELETE FROM repo_images WHERE id IN \((\?, )*\?\)$/,
} as const;

function normalizeQuery(query: string): string {
Expand Down Expand Up @@ -134,6 +137,17 @@ class FakeD1Database {
return results.sort((a, b) => b.created_at - a.created_at).slice(0, 10);
}

if (QUERY_PATTERNS.SELECT_STORED_FOR_REPO.test(normalized)) {
const [owner, name] = args as [string, string];
const results: Array<Pick<RepoImageRow, "id" | "provider_image_id">> = [];
for (const row of this.rows.values()) {
if (row.repo_owner === owner && row.repo_name === name && row.status !== "building") {
results.push({ id: row.id, provider_image_id: row.provider_image_id });
}
}
return results;
}

if (QUERY_PATTERNS.SELECT_ALL_STATUS.test(normalized)) {
const results: RepoImageRow[] = [];
for (const row of this.rows.values()) {
Expand Down Expand Up @@ -227,6 +241,18 @@ class FakeD1Database {
return { meta: { changes } };
}

if (QUERY_PATTERNS.DELETE_STORED_FOR_REPO_BY_IDS.test(normalized)) {
let changes = 0;
const ids = new Set(args as string[]);
for (const id of this.rows.keys()) {
if (ids.has(id)) {
this.rows.delete(id);
changes++;
}
}
return { meta: { changes } };
}

throw new Error(`Unexpected mutation query: ${normalized}`);
}

Expand Down Expand Up @@ -600,6 +626,106 @@ describe("RepoImageStore", () => {
});
});

describe("deleteStoredImagesForRepo", () => {
it("returns stored rows without building entries", async () => {
await store.registerBuild({
id: "img-building",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});

await store.registerBuild({
id: "img-ready",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});
await store.markReady("img-ready", "modal-img-ready", "sha-ready", 30);

const stored = await store.getStoredImagesForRepo("acme", "repo");

expect(stored).toEqual([{ id: "img-ready", provider_image_id: "modal-img-ready" }]);
});

it("deletes only the requested stored rows while keeping building rows", async () => {
await store.registerBuild({
id: "img-building",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});

await store.registerBuild({
id: "img-ready",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});
await store.markReady("img-ready", "modal-img-ready", "sha-ready", 30);

await store.registerBuild({
id: "img-failed",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});
await store.markFailed("img-failed", "failed");

const deleted = await store.deleteStoredImagesForRepo(["img-ready", "img-failed"]);

expect(deleted).toBe(2);

const status = await store.getStatus("acme", "repo");
expect(status).toHaveLength(1);
expect(status[0].id).toBe("img-building");
expect(status[0].status).toBe("building");
});

it("returns zero when there are no stored images", async () => {
await store.registerBuild({
id: "img-building",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});

const deleted = await store.deleteStoredImagesForRepo([]);

expect(deleted).toBe(0);
});

it("does not delete rows that become stored after the fetch snapshot", async () => {
await store.registerBuild({
id: "img-ready",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});
await store.markReady("img-ready", "modal-img-ready", "sha-ready", 30);

const stored = await store.getStoredImagesForRepo("acme", "repo");
expect(stored).toEqual([{ id: "img-ready", provider_image_id: "modal-img-ready" }]);

await store.registerBuild({
id: "img-late-failed",
repoOwner: "acme",
repoName: "repo",
baseBranch: "main",
});
await store.markFailed("img-late-failed", "failed later");

const deleted = await store.deleteStoredImagesForRepo(stored.map((image) => image.id));

expect(deleted).toBe(1);

const status = await store.getStatus("acme", "repo");
expect(status).toHaveLength(1);
expect(status[0].id).toBe("img-late-failed");
expect(status[0].status).toBe("failed");
});
});

describe("markStaleBuildsAsFailed", () => {
it("marks old building rows as failed", async () => {
await store.registerBuild({
Expand Down
32 changes: 32 additions & 0 deletions packages/control-plane/src/db/repo-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,41 @@ export interface RepoImage {
created_at: number;
}

export interface StoredRepoImage {
id: string;
provider_image_id: string;
}

export class RepoImageStore {
constructor(private readonly db: D1Database) {}

async getStoredImagesForRepo(repoOwner: string, repoName: string): Promise<StoredRepoImage[]> {
const normalizedOwner = repoOwner.toLowerCase();
const normalizedName = repoName.toLowerCase();
const storedImages = await this.db
.prepare(
`SELECT id, provider_image_id FROM repo_images
WHERE repo_owner = ? AND repo_name = ? AND status != 'building'`
)
.bind(normalizedOwner, normalizedName)
.all<StoredRepoImage>();

return storedImages.results || [];
}

async deleteStoredImagesForRepo(ids: readonly string[]): Promise<number> {
const normalizedIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
if (normalizedIds.length === 0) return 0;

const placeholders = normalizedIds.map(() => "?").join(", ");
const result = await this.db
.prepare(`DELETE FROM repo_images WHERE id IN (${placeholders})`)
.bind(...normalizedIds)
.run();

return result.meta?.changes ?? 0;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async registerBuild(build: RepoImageBuild): Promise<void> {
const now = Date.now();
await this.db
Expand Down
127 changes: 127 additions & 0 deletions packages/control-plane/src/routes/repo-images.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { repoImageRoutes } from "./repo-images";
import type { RequestContext } from "./shared";
import type { Env } from "../types";

const mockRepoImageStore = {
registerBuild: vi.fn(),
getStoredImagesForRepo: vi.fn(),
deleteStoredImagesForRepo: vi.fn(),
};

const mockModalClient = {
buildRepoImage: vi.fn(),
deleteProviderImage: vi.fn(),
};

vi.mock("../db/repo-images", () => ({
RepoImageStore: vi.fn().mockImplementation(() => mockRepoImageStore),
}));

vi.mock("../sandbox/client", () => ({
createModalClient: vi.fn(() => mockModalClient),
}));

vi.mock("./shared", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
resolveRepoOrError: vi.fn().mockResolvedValue({
repoId: 123,
repoOwner: "acme",
repoName: "repo",
defaultBranch: "develop",
}),
};
});

function getHandler(method: string, path: string) {
for (const route of repoImageRoutes) {
if (route.method === method && route.pattern.test(path)) {
const match = path.match(route.pattern)!;
return { handler: route.handler, match };
}
}
throw new Error(`No route found for ${method} ${path}`);
}

function createEnv(): Env {
return {
DB: {} as D1Database,
SANDBOX_PROVIDER: "modal",
MODAL_API_SECRET: "secret",
MODAL_WORKSPACE: "workspace",
WORKER_URL: "https://worker.test",
} as Env;
}

function createCtx(): RequestContext {
return {
trace_id: "trace-1",
request_id: "req-1",
metrics: {
d1Queries: [],
spans: {},
time: async <T>(_name: string, fn: () => Promise<T>) => fn(),
summarize: () => ({}),
},
};
}

describe("repo image route handlers", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRepoImageStore.registerBuild.mockResolvedValue(undefined);
mockRepoImageStore.getStoredImagesForRepo.mockResolvedValue([]);
mockRepoImageStore.deleteStoredImagesForRepo.mockResolvedValue(0);
mockModalClient.buildRepoImage.mockResolvedValue({ buildId: "img-test", status: "building" });
mockModalClient.deleteProviderImage.mockResolvedValue({
providerImageId: "modal-img-1",
deleted: true,
});
});

it("uses the resolved repo default branch when triggering a build", async () => {
const { handler, match } = getHandler("POST", "/repo-images/trigger/acme/repo");

const response = await handler(
new Request("https://test.local/repo-images/trigger/acme/repo", { method: "POST" }),
createEnv(),
match,
createCtx()
);

expect(response.status).toBe(200);
expect(mockRepoImageStore.registerBuild).toHaveBeenCalledWith(
expect.objectContaining({ repoOwner: "acme", repoName: "repo", baseBranch: "develop" })
);
expect(mockModalClient.buildRepoImage).toHaveBeenCalledWith(
expect.objectContaining({ repoOwner: "acme", repoName: "repo", defaultBranch: "develop" }),
expect.objectContaining({ trace_id: "trace-1", request_id: "req-1" })
);
});

it("deletes provider images before removing stored records", async () => {
mockRepoImageStore.getStoredImagesForRepo.mockResolvedValue([
{ id: "img-1", provider_image_id: "modal-img-1" },
{ id: "img-2", provider_image_id: "" },
]);
mockRepoImageStore.deleteStoredImagesForRepo.mockResolvedValue(2);

const { handler, match } = getHandler("DELETE", "/repo-images/acme/repo");

const response = await handler(
new Request("https://test.local/repo-images/acme/repo", { method: "DELETE" }),
createEnv(),
match,
createCtx()
);

expect(response.status).toBe(200);
expect(mockModalClient.deleteProviderImage).toHaveBeenCalledWith(
{ providerImageId: "modal-img-1" },
expect.objectContaining({ trace_id: "trace-1", request_id: "req-1" })
);
expect(mockRepoImageStore.deleteStoredImagesForRepo).toHaveBeenCalledWith(["img-1", "img-2"]);
});
});
Loading