405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
import pg from "pg";
|
|
|
|
import { buildPostgresPoolConfig } from "../db/runtime-store.ts";
|
|
|
|
const { Pool } = pg;
|
|
|
|
const schemaStatements = Object.freeze([
|
|
`CREATE TABLE IF NOT EXISTS project_management_schema_migrations (
|
|
migration_id TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS project_sources (
|
|
source_id TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
project_id TEXT NOT NULL,
|
|
root_ref TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
source_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS mdtodo_documents (
|
|
source_id TEXT NOT NULL REFERENCES project_sources(source_id) ON DELETE CASCADE,
|
|
file_ref TEXT NOT NULL,
|
|
project_id TEXT NOT NULL,
|
|
relative_path TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
fingerprint TEXT NOT NULL,
|
|
task_count INTEGER NOT NULL DEFAULT 0,
|
|
parse_error TEXT,
|
|
revision INTEGER NOT NULL DEFAULT 1,
|
|
document_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (source_id, file_ref)
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS mdtodo_task_projection (
|
|
task_ref TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
source_id TEXT NOT NULL,
|
|
file_ref TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
parent_task_ref TEXT,
|
|
depth INTEGER NOT NULL DEFAULT 0,
|
|
line_number INTEGER NOT NULL DEFAULT 0,
|
|
ordinal INTEGER NOT NULL DEFAULT 0,
|
|
link_count INTEGER NOT NULL DEFAULT 0,
|
|
source_fingerprint TEXT NOT NULL,
|
|
task_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS project_workbench_links (
|
|
link_id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
task_ref TEXT,
|
|
session_id TEXT,
|
|
trace_id TEXT,
|
|
created_by TEXT,
|
|
role TEXT NOT NULL DEFAULT 'reference',
|
|
link_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS project_management_audit_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
actor_id TEXT,
|
|
action TEXT NOT NULL,
|
|
target_type TEXT NOT NULL,
|
|
target_id TEXT NOT NULL,
|
|
outcome TEXT NOT NULL,
|
|
event_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS project_management_outbox (
|
|
event_id TEXT PRIMARY KEY,
|
|
aggregate_type TEXT NOT NULL,
|
|
aggregate_id TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
event_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
published_at TIMESTAMPTZ
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_mdtodo_documents_project ON mdtodo_documents(project_id, relative_path)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_mdtodo_tasks_project_file ON mdtodo_task_projection(project_id, source_id, file_ref, ordinal)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_mdtodo_tasks_parent ON mdtodo_task_projection(parent_task_ref)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_project_workbench_links_task ON project_workbench_links(task_ref, updated_at DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_project_outbox_status ON project_management_outbox(status, created_at)`
|
|
]);
|
|
|
|
export function createProjectManagementStore(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const kind = String(options.kind ?? env.HWLAB_PROJECT_MANAGEMENT_STORE ?? "postgres").trim().toLowerCase();
|
|
if (kind === "memory") return new MemoryProjectManagementStore(options);
|
|
|
|
const dbUrl = options.dbUrl ?? env.HWLAB_PROJECT_MANAGEMENT_DB_URL;
|
|
if (!dbUrl) return new UnconfiguredProjectManagementStore("HWLAB_PROJECT_MANAGEMENT_DB_URL");
|
|
return new PostgresProjectManagementStore({
|
|
...options,
|
|
dbUrl,
|
|
sslMode: options.sslMode ?? env.HWLAB_PROJECT_MANAGEMENT_DB_SSL_MODE,
|
|
timeoutMs: options.timeoutMs ?? env.HWLAB_PROJECT_MANAGEMENT_DB_CONNECT_TIMEOUT_MS,
|
|
poolMax: options.poolMax ?? env.HWLAB_PROJECT_MANAGEMENT_DB_POOL_MAX,
|
|
queryTimeoutMs: options.queryTimeoutMs ?? env.HWLAB_PROJECT_MANAGEMENT_DB_QUERY_TIMEOUT_MS
|
|
});
|
|
}
|
|
|
|
export class PostgresProjectManagementStore {
|
|
constructor(options = {}) {
|
|
this.pool = options.pool ?? new Pool(buildPostgresPoolConfig({
|
|
dbUrl: options.dbUrl,
|
|
sslMode: options.sslMode,
|
|
timeoutMs: options.timeoutMs,
|
|
poolMax: options.poolMax,
|
|
queryTimeoutMs: options.queryTimeoutMs
|
|
}));
|
|
this.schemaReady = false;
|
|
}
|
|
|
|
async ensureSchema() {
|
|
if (this.schemaReady) return;
|
|
const client = await this.pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
for (const statement of schemaStatements) await client.query(statement);
|
|
await client.query(
|
|
"INSERT INTO project_management_schema_migrations (migration_id) VALUES ($1) ON CONFLICT (migration_id) DO NOTHING",
|
|
["project-management-20260625-p1"]
|
|
);
|
|
await client.query("COMMIT");
|
|
this.schemaReady = true;
|
|
} catch (error) {
|
|
await client.query("ROLLBACK").catch(() => {});
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async upsertSource(source) {
|
|
await this.ensureSchema();
|
|
await this.pool.query(
|
|
`INSERT INTO project_sources (source_id, kind, display_name, project_id, root_ref, status, source_json, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, now())
|
|
ON CONFLICT (source_id) DO UPDATE SET
|
|
kind = EXCLUDED.kind,
|
|
display_name = EXCLUDED.display_name,
|
|
project_id = EXCLUDED.project_id,
|
|
root_ref = EXCLUDED.root_ref,
|
|
status = EXCLUDED.status,
|
|
source_json = EXCLUDED.source_json,
|
|
updated_at = now()`,
|
|
[source.sourceId, source.kind, source.displayName, source.projectId, source.rootRef, source.status, JSON.stringify(source)]
|
|
);
|
|
}
|
|
|
|
async replaceProjection(source, documents) {
|
|
await this.ensureSchema();
|
|
const client = await this.pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
await client.query("DELETE FROM mdtodo_task_projection WHERE source_id = $1", [source.sourceId]);
|
|
await client.query("DELETE FROM mdtodo_documents WHERE source_id = $1", [source.sourceId]);
|
|
for (const document of documents) {
|
|
await client.query(
|
|
`INSERT INTO mdtodo_documents (source_id, file_ref, project_id, relative_path, title, fingerprint, task_count, document_json, last_indexed_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())`,
|
|
[source.sourceId, document.fileRef, document.projectId, document.relativePath, document.title, document.fingerprint, document.taskCount, JSON.stringify(document.document)]
|
|
);
|
|
for (const task of document.tasks) {
|
|
await client.query(
|
|
`INSERT INTO mdtodo_task_projection
|
|
(task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, now())`,
|
|
[task.taskRef, task.projectId, task.sourceId, task.fileRef, task.taskId, task.title, task.status, task.parentTaskRef, task.depth, task.lineNumber, task.ordinal, task.linkCount, task.sourceFingerprint, JSON.stringify(task)]
|
|
);
|
|
}
|
|
}
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK").catch(() => {});
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
async listSources() {
|
|
await this.ensureSchema();
|
|
const result = await this.pool.query(
|
|
`SELECT source_id, kind, display_name, project_id, root_ref, status, source_json, updated_at
|
|
FROM project_sources ORDER BY source_id`
|
|
);
|
|
return result.rows.map((row) => ({
|
|
sourceId: row.source_id,
|
|
kind: row.kind,
|
|
displayName: row.display_name,
|
|
projectId: row.project_id,
|
|
rootRef: row.root_ref,
|
|
status: row.status,
|
|
updatedAt: row.updated_at,
|
|
...(row.source_json ?? {})
|
|
}));
|
|
}
|
|
|
|
async listDocuments(filters = {}) {
|
|
await this.ensureSchema();
|
|
const params = [];
|
|
const clauses = [];
|
|
if (filters.sourceId) {
|
|
params.push(filters.sourceId);
|
|
clauses.push(`source_id = $${params.length}`);
|
|
}
|
|
const result = await this.pool.query(
|
|
`SELECT source_id, file_ref, project_id, relative_path, title, fingerprint, task_count, parse_error, revision, document_json, last_indexed_at, updated_at
|
|
FROM mdtodo_documents ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
ORDER BY project_id, relative_path`,
|
|
params
|
|
);
|
|
return result.rows.map((row) => ({
|
|
sourceId: row.source_id,
|
|
fileRef: row.file_ref,
|
|
projectId: row.project_id,
|
|
relativePath: row.relative_path,
|
|
title: row.title,
|
|
fingerprint: row.fingerprint,
|
|
taskCount: row.task_count,
|
|
parseError: row.parse_error,
|
|
revision: row.revision,
|
|
lastIndexedAt: row.last_indexed_at,
|
|
updatedAt: row.updated_at,
|
|
document: row.document_json ?? {}
|
|
}));
|
|
}
|
|
|
|
async listTasks(filters = {}) {
|
|
await this.ensureSchema();
|
|
const params = [];
|
|
const clauses = [];
|
|
for (const [column, value] of [["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId]]) {
|
|
if (!value) continue;
|
|
params.push(value);
|
|
clauses.push(`${column} = $${params.length}`);
|
|
}
|
|
const result = await this.pool.query(
|
|
`SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at
|
|
FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
ORDER BY project_id, source_id, file_ref, ordinal`,
|
|
params
|
|
);
|
|
return result.rows.map((row) => ({
|
|
taskRef: row.task_ref,
|
|
projectId: row.project_id,
|
|
sourceId: row.source_id,
|
|
fileRef: row.file_ref,
|
|
taskId: row.task_id,
|
|
title: row.title,
|
|
status: row.status,
|
|
parentTaskRef: row.parent_task_ref,
|
|
depth: row.depth,
|
|
lineNumber: row.line_number,
|
|
ordinal: row.ordinal,
|
|
linkCount: row.link_count,
|
|
sourceFingerprint: row.source_fingerprint,
|
|
updatedAt: row.updated_at,
|
|
task: row.task_json ?? {}
|
|
}));
|
|
}
|
|
|
|
async listWorkbenchLinks(filters = {}) {
|
|
await this.ensureSchema();
|
|
const params = [];
|
|
const clauses = [];
|
|
for (const [column, value] of [["task_ref", filters.taskRef], ["project_id", filters.projectId], ["session_id", filters.sessionId]]) {
|
|
if (!value) continue;
|
|
params.push(value);
|
|
clauses.push(`${column} = $${params.length}`);
|
|
}
|
|
const result = await this.pool.query(
|
|
`SELECT link_id, project_id, task_ref, session_id, trace_id, created_by, role, link_json, created_at, updated_at
|
|
FROM project_workbench_links ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
ORDER BY updated_at DESC, link_id LIMIT 200`,
|
|
params
|
|
);
|
|
return result.rows.map(workbenchLinkFromRow);
|
|
}
|
|
|
|
async upsertWorkbenchLink(link) {
|
|
await this.ensureSchema();
|
|
const result = await this.pool.query(
|
|
`INSERT INTO project_workbench_links (link_id, project_id, task_ref, session_id, trace_id, created_by, role, link_json, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
|
|
ON CONFLICT (link_id) DO UPDATE SET
|
|
project_id = EXCLUDED.project_id,
|
|
task_ref = EXCLUDED.task_ref,
|
|
session_id = EXCLUDED.session_id,
|
|
trace_id = EXCLUDED.trace_id,
|
|
created_by = COALESCE(EXCLUDED.created_by, project_workbench_links.created_by),
|
|
role = EXCLUDED.role,
|
|
link_json = EXCLUDED.link_json,
|
|
updated_at = now()
|
|
RETURNING link_id, project_id, task_ref, session_id, trace_id, created_by, role, link_json, created_at, updated_at`,
|
|
[link.linkId, link.projectId, link.taskRef ?? null, link.sessionId ?? null, link.traceId ?? null, link.createdBy ?? null, link.role ?? "launch", JSON.stringify(link.link ?? {})]
|
|
);
|
|
if (link.taskRef) {
|
|
await this.pool.query(
|
|
`UPDATE mdtodo_task_projection
|
|
SET link_count = (SELECT COUNT(*)::int FROM project_workbench_links WHERE task_ref = $1), updated_at = now()
|
|
WHERE task_ref = $1`,
|
|
[link.taskRef]
|
|
);
|
|
}
|
|
return workbenchLinkFromRow(result.rows[0]);
|
|
}
|
|
|
|
async close() {
|
|
await this.pool.end();
|
|
}
|
|
}
|
|
|
|
class MemoryProjectManagementStore {
|
|
constructor() {
|
|
this.sources = new Map();
|
|
this.documents = [];
|
|
this.tasks = [];
|
|
this.links = [];
|
|
}
|
|
|
|
async ensureSchema() {}
|
|
async close() {}
|
|
|
|
async upsertSource(source) {
|
|
this.sources.set(source.sourceId, source);
|
|
}
|
|
|
|
async replaceProjection(source, documents) {
|
|
this.documents = this.documents.filter((document) => document.sourceId !== source.sourceId);
|
|
this.tasks = this.tasks.filter((task) => task.sourceId !== source.sourceId);
|
|
this.documents.push(...documents.map((document) => ({ ...document, lastIndexedAt: new Date().toISOString(), updatedAt: new Date().toISOString() })));
|
|
this.tasks.push(...documents.flatMap((document) => document.tasks.map((task) => ({ ...task, updatedAt: new Date().toISOString(), task }))));
|
|
}
|
|
|
|
async listSources() { return [...this.sources.values()]; }
|
|
async listDocuments(filters = {}) { return this.documents.filter((item) => !filters.sourceId || item.sourceId === filters.sourceId); }
|
|
async listTasks(filters = {}) {
|
|
return this.tasks.filter((item) => (!filters.sourceId || item.sourceId === filters.sourceId) && (!filters.fileRef || item.fileRef === filters.fileRef) && (!filters.projectId || item.projectId === filters.projectId));
|
|
}
|
|
async listWorkbenchLinks(filters = {}) {
|
|
return this.links.filter((item) => (!filters.taskRef || item.taskRef === filters.taskRef) && (!filters.projectId || item.projectId === filters.projectId) && (!filters.sessionId || item.sessionId === filters.sessionId));
|
|
}
|
|
async upsertWorkbenchLink(link) {
|
|
const now = new Date().toISOString();
|
|
const existingIndex = this.links.findIndex((item) => item.linkId === link.linkId);
|
|
const existing = existingIndex >= 0 ? this.links[existingIndex] : null;
|
|
const saved = { ...existing, ...link, role: link.role ?? "launch", link: link.link ?? existing?.link ?? {}, createdAt: existing?.createdAt ?? now, updatedAt: now };
|
|
if (existingIndex >= 0) this.links.splice(existingIndex, 1, saved);
|
|
else this.links.unshift(saved);
|
|
if (link.taskRef) {
|
|
const count = this.links.filter((item) => item.taskRef === link.taskRef).length;
|
|
this.tasks = this.tasks.map((task) => task.taskRef === link.taskRef ? { ...task, linkCount: count, updatedAt: now } : task);
|
|
}
|
|
return saved;
|
|
}
|
|
}
|
|
|
|
class UnconfiguredProjectManagementStore {
|
|
constructor(envName) {
|
|
this.envName = envName;
|
|
}
|
|
|
|
async ensureSchema() {
|
|
const error = new Error(`${this.envName} is required for hwlab-project-management`);
|
|
error.code = "project_management_db_unconfigured";
|
|
error.statusCode = 503;
|
|
throw error;
|
|
}
|
|
|
|
async close() {}
|
|
async upsertSource() { await this.ensureSchema(); }
|
|
async replaceProjection() { await this.ensureSchema(); }
|
|
async listSources() { await this.ensureSchema(); }
|
|
async listDocuments() { await this.ensureSchema(); }
|
|
async listTasks() { await this.ensureSchema(); }
|
|
async listWorkbenchLinks() { await this.ensureSchema(); }
|
|
async upsertWorkbenchLink() { await this.ensureSchema(); }
|
|
}
|
|
|
|
function workbenchLinkFromRow(row) {
|
|
return {
|
|
linkId: row.link_id,
|
|
projectId: row.project_id,
|
|
taskRef: row.task_ref,
|
|
sessionId: row.session_id,
|
|
traceId: row.trace_id,
|
|
createdBy: row.created_by,
|
|
role: row.role,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
link: row.link_json ?? {}
|
|
};
|
|
}
|