Files
pikasTech-HWLAB/internal/project-management/store.ts
T
2026-06-25 17:59:19 +08:00

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 ?? {}
};
}