mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user