Phase 2b: DB-backed project and workspace registry adapters

Add DbProjectRegistry and DbWorkspaceRegistry implementing the existing
ProjectRegistry/WorkspaceRegistry interfaces against Drizzle/PGlite.
Real PGlite contract tests verify full behavioral parity with the
file-backed implementations including FK constraints.

No runtime rewiring — bootstrap and consumers untouched.
This commit is contained in:
Mohamed Boudra
2026-03-25 20:39:07 +07:00
parent 214f85a150
commit 7f11c0c64c
3 changed files with 305 additions and 0 deletions
@@ -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<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedProjectRecord[]> {
const rows = await this.db.select().from(projects);
return rows.map(toPersistedProjectRecord);
}
async get(projectId: string): Promise<PersistedProjectRecord | null> {
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<void> {
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<void> {
await this.db
.update(projects)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(projects.projectId, projectId));
}
async remove(projectId: string): Promise<void> {
await this.db.delete(projects).where(eq(projects.projectId, projectId));
}
}
@@ -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> = {}): 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> = {}): 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());
});
});
@@ -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<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedWorkspaceRecord[]> {
const rows = await this.db.select().from(workspaces);
return rows.map(toPersistedWorkspaceRecord);
}
async get(workspaceId: string): Promise<PersistedWorkspaceRecord | null> {
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<void> {
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<void> {
await this.db
.update(workspaces)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(workspaces.workspaceId, workspaceId));
}
async remove(workspaceId: string): Promise<void> {
await this.db.delete(workspaces).where(eq(workspaces.workspaceId, workspaceId));
}
}