diff --git a/packages/server/src/server/db/db-project-registry.ts b/packages/server/src/server/db/db-project-registry.ts new file mode 100644 index 0000000000..11b6059eba --- /dev/null +++ b/packages/server/src/server/db/db-project-registry.ts @@ -0,0 +1,73 @@ +import { eq } from "drizzle-orm"; + +import type { PersistedProjectKind } from "../workspace-registry-model.js"; +import type { ProjectRegistry, PersistedProjectRecord } from "../workspace-registry.js"; +import { createPersistedProjectRecord } from "../workspace-registry.js"; +import { projects } from "./schema.js"; +import type { PaseoDatabaseHandle } from "./pglite-database.js"; + +function toPersistedProjectRecord(row: typeof projects.$inferSelect): PersistedProjectRecord { + return createPersistedProjectRecord({ + ...row, + kind: row.kind as PersistedProjectKind, + }); +} + +export class DbProjectRegistry implements ProjectRegistry { + private readonly db: PaseoDatabaseHandle["db"]; + + constructor(db: PaseoDatabaseHandle["db"]) { + this.db = db; + } + + async initialize(): Promise { + return Promise.resolve(); + } + + async existsOnDisk(): Promise { + return true; + } + + async list(): Promise { + const rows = await this.db.select().from(projects); + return rows.map(toPersistedProjectRecord); + } + + async get(projectId: string): Promise { + const rows = await this.db.select().from(projects).where(eq(projects.projectId, projectId)).limit(1); + const row = rows[0]; + return row ? toPersistedProjectRecord(row) : null; + } + + async upsert(record: PersistedProjectRecord): Promise { + const nextRecord = createPersistedProjectRecord(record); + await this.db + .insert(projects) + .values(nextRecord) + .onConflictDoUpdate({ + target: projects.projectId, + set: { + rootPath: nextRecord.rootPath, + kind: nextRecord.kind, + displayName: nextRecord.displayName, + createdAt: nextRecord.createdAt, + updatedAt: nextRecord.updatedAt, + archivedAt: nextRecord.archivedAt, + }, + }); + } + + async archive(projectId: string, archivedAt: string): Promise { + await this.db + .update(projects) + .set({ + updatedAt: archivedAt, + archivedAt, + }) + .where(eq(projects.projectId, projectId)); + } + + async remove(projectId: string): Promise { + await this.db.delete(projects).where(eq(projects.projectId, projectId)); + } +} diff --git a/packages/server/src/server/db/db-workspace-registry.test.ts b/packages/server/src/server/db/db-workspace-registry.test.ts new file mode 100644 index 0000000000..84d921a5d9 --- /dev/null +++ b/packages/server/src/server/db/db-workspace-registry.test.ts @@ -0,0 +1,154 @@ +import os from "node:os"; +import path from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import type { PersistedProjectRecord, PersistedWorkspaceRecord, ProjectRegistry, WorkspaceRegistry } from "../workspace-registry.js"; +import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from "../workspace-registry.js"; +import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js"; +import { DbProjectRegistry } from "./db-project-registry.js"; +import { DbWorkspaceRegistry } from "./db-workspace-registry.js"; + +function createProjectRecord(input: Partial = {}): PersistedProjectRecord { + return createPersistedProjectRecord({ + projectId: "remote:github.com/acme/repo", + rootPath: "/tmp/repo", + kind: "git", + displayName: "acme/repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + ...input, + }); +} + +function createWorkspaceRecord(input: Partial = {}): PersistedWorkspaceRecord { + return createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + archivedAt: null, + ...input, + }); +} + +describe("DB-backed workspace registries", () => { + let tmpDir: string; + let dataDir: string; + let database: PaseoDatabaseHandle; + let projectRegistry: ProjectRegistry; + let workspaceRegistry: WorkspaceRegistry; + + beforeEach(async () => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-workspace-registry-")); + dataDir = path.join(tmpDir, "db"); + database = await openPaseoDatabase(dataDir); + projectRegistry = new DbProjectRegistry(database.db); + workspaceRegistry = new DbWorkspaceRegistry(database.db); + }); + + afterEach(async () => { + await database.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("project registry matches the file-backed behavioral contract", async () => { + await projectRegistry.initialize(); + expect(await projectRegistry.existsOnDisk()).toBe(true); + expect(await projectRegistry.get("missing-project")).toBeNull(); + expect(await projectRegistry.list()).toEqual([]); + + await projectRegistry.upsert(createProjectRecord()); + await projectRegistry.upsert( + createProjectRecord({ + updatedAt: "2026-03-02T00:00:00.000Z", + }), + ); + await projectRegistry.archive( + "remote:github.com/acme/repo", + "2026-03-03T00:00:00.000Z", + ); + await projectRegistry.archive("missing-project", "2026-03-04T00:00:00.000Z"); + + expect(await projectRegistry.get("remote:github.com/acme/repo")).toEqual( + createProjectRecord({ + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ); + expect(await projectRegistry.list()).toEqual([ + createProjectRecord({ + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ]); + + await projectRegistry.remove("missing-project"); + await projectRegistry.remove("remote:github.com/acme/repo"); + + expect(await projectRegistry.get("remote:github.com/acme/repo")).toBeNull(); + expect(await projectRegistry.list()).toEqual([]); + }); + + test("workspace registry matches the file-backed behavioral contract", async () => { + await workspaceRegistry.initialize(); + expect(await workspaceRegistry.existsOnDisk()).toBe(true); + expect(await workspaceRegistry.get("missing-workspace")).toBeNull(); + expect(await workspaceRegistry.list()).toEqual([]); + + await projectRegistry.upsert(createProjectRecord()); + await workspaceRegistry.upsert(createWorkspaceRecord()); + await workspaceRegistry.upsert( + createWorkspaceRecord({ + displayName: "feature/workspace", + updatedAt: "2026-03-02T00:00:00.000Z", + }), + ); + await workspaceRegistry.archive("/tmp/repo", "2026-03-03T00:00:00.000Z"); + await workspaceRegistry.archive("missing-workspace", "2026-03-04T00:00:00.000Z"); + + expect(await workspaceRegistry.get("/tmp/repo")).toEqual( + createWorkspaceRecord({ + displayName: "feature/workspace", + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ); + expect(await workspaceRegistry.list()).toEqual([ + createWorkspaceRecord({ + displayName: "feature/workspace", + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }), + ]); + + await workspaceRegistry.remove("missing-workspace"); + await workspaceRegistry.remove("/tmp/repo"); + + expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); + expect(await workspaceRegistry.list()).toEqual([]); + }); + + test("rejects workspace upserts for non-existent projects", async () => { + await expect( + workspaceRegistry.upsert( + createWorkspaceRecord({ + projectId: "remote:github.com/acme/missing", + }), + ), + ).rejects.toThrow(); + }); + + test("rejects project removal while linked workspaces exist", async () => { + await projectRegistry.upsert(createProjectRecord()); + await workspaceRegistry.upsert(createWorkspaceRecord()); + + await expect(projectRegistry.remove("remote:github.com/acme/repo")).rejects.toThrow(); + expect(await projectRegistry.get("remote:github.com/acme/repo")).toEqual(createProjectRecord()); + }); +}); diff --git a/packages/server/src/server/db/db-workspace-registry.ts b/packages/server/src/server/db/db-workspace-registry.ts new file mode 100644 index 0000000000..30bbb57e12 --- /dev/null +++ b/packages/server/src/server/db/db-workspace-registry.ts @@ -0,0 +1,78 @@ +import { eq } from "drizzle-orm"; + +import type { PaseoDatabaseHandle } from "./pglite-database.js"; +import { workspaces } from "./schema.js"; +import type { PersistedWorkspaceKind } from "../workspace-registry-model.js"; +import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js"; +import { createPersistedWorkspaceRecord } from "../workspace-registry.js"; + +function toPersistedWorkspaceRecord(row: typeof workspaces.$inferSelect): PersistedWorkspaceRecord { + return createPersistedWorkspaceRecord({ + ...row, + kind: row.kind as PersistedWorkspaceKind, + }); +} + +export class DbWorkspaceRegistry implements WorkspaceRegistry { + private readonly db: PaseoDatabaseHandle["db"]; + + constructor(db: PaseoDatabaseHandle["db"]) { + this.db = db; + } + + async initialize(): Promise { + return Promise.resolve(); + } + + async existsOnDisk(): Promise { + return true; + } + + async list(): Promise { + const rows = await this.db.select().from(workspaces); + return rows.map(toPersistedWorkspaceRecord); + } + + async get(workspaceId: string): Promise { + const rows = await this.db + .select() + .from(workspaces) + .where(eq(workspaces.workspaceId, workspaceId)) + .limit(1); + const row = rows[0]; + return row ? toPersistedWorkspaceRecord(row) : null; + } + + async upsert(record: PersistedWorkspaceRecord): Promise { + const nextRecord = createPersistedWorkspaceRecord(record); + await this.db + .insert(workspaces) + .values(nextRecord) + .onConflictDoUpdate({ + target: workspaces.workspaceId, + set: { + projectId: nextRecord.projectId, + cwd: nextRecord.cwd, + kind: nextRecord.kind, + displayName: nextRecord.displayName, + createdAt: nextRecord.createdAt, + updatedAt: nextRecord.updatedAt, + archivedAt: nextRecord.archivedAt, + }, + }); + } + + async archive(workspaceId: string, archivedAt: string): Promise { + await this.db + .update(workspaces) + .set({ + updatedAt: archivedAt, + archivedAt, + }) + .where(eq(workspaces.workspaceId, workspaceId)); + } + + async remove(workspaceId: string): Promise { + await this.db.delete(workspaces).where(eq(workspaces.workspaceId, workspaceId)); + } +}