Files
fusion/packages/core/src/goal-store.ts
gsxdsm 2a3535893b feat(FN-5622): add goals REST API with store accessor and route handlers
Introduces a Goals REST API (`GET/POST/PUT /api/goals` and `GET/PUT /api/goals/:id`) backed by a new `@fusion/core` goal store and typed goal types, including comprehensive route and store test coverage. Documentation on architecture and storage is updated to reflect the new domain, and a changeset

Fusion-Task-Id: FN-5622

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5622
2026-05-28 10:32:16 -07:00

205 lines
5.5 KiB
TypeScript

import { EventEmitter } from "node:events";
import type { Database } from "./db.js";
import {
ACTIVE_GOAL_LIMIT,
ActiveGoalLimitExceededError,
type Goal,
type GoalCreateInput,
type GoalListFilter,
type GoalStatus,
type GoalUpdateInput,
} from "./goal-types.js";
interface GoalRow {
id: string;
title: string;
description: string | null;
status: GoalStatus;
createdAt: string;
updatedAt: string;
}
export interface GoalStoreEvents {
"goal:created": [Goal];
"goal:updated": [Goal];
}
export class GoalStore extends EventEmitter<GoalStoreEvents> {
private idSequence = 0;
public constructor(
_fusionDir: string,
private readonly db: Database,
) {
super();
}
public createGoal(input: GoalCreateInput): Goal {
const now = new Date().toISOString();
const goal = this.db.transactionImmediate(() => {
const activeCountRow = this.db
.prepare("SELECT COUNT(*) as count FROM goals WHERE status = 'active'")
.get() as { count: number } | undefined;
const currentActive = activeCountRow?.count ?? 0;
if (currentActive >= ACTIVE_GOAL_LIMIT) {
throw new ActiveGoalLimitExceededError(ACTIVE_GOAL_LIMIT, currentActive);
}
const created: Goal = {
id: this.generateGoalId(),
title: input.title,
description: input.description,
status: "active",
createdAt: now,
updatedAt: now,
};
this.db
.prepare(
"INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(created.id, created.title, created.description ?? null, created.status, created.createdAt, created.updatedAt);
return created;
});
this.db.bumpLastModified();
this.emit("goal:created", goal);
return goal;
}
public updateGoal(id: string, input: GoalUpdateInput): Goal {
const existing = this.getGoal(id);
if (!existing) {
throw new Error(`Goal ${id} not found`);
}
const updated: Goal = {
...existing,
title: input.title ?? existing.title,
description: input.description ?? existing.description,
updatedAt: new Date().toISOString(),
};
this.db
.prepare("UPDATE goals SET title = ?, description = ?, updatedAt = ? WHERE id = ?")
.run(updated.title, updated.description ?? null, updated.updatedAt, id);
this.db.bumpLastModified();
this.emit("goal:updated", updated);
return updated;
}
public archiveGoal(id: string): Goal {
const existing = this.getGoal(id);
if (!existing) {
throw new Error(`Goal ${id} not found`);
}
if (existing.status === "archived") {
this.emit("goal:updated", existing);
return existing;
}
const updated: Goal = {
...existing,
status: "archived",
updatedAt: new Date().toISOString(),
};
this.db
.prepare("UPDATE goals SET status = 'archived', updatedAt = ? WHERE id = ?")
.run(updated.updatedAt, id);
this.db.bumpLastModified();
this.emit("goal:updated", updated);
return updated;
}
public unarchiveGoal(id: string): Goal {
const { goal, changed } = this.db.transactionImmediate(() => {
const existing = this.getGoal(id);
if (!existing) {
throw new Error(`Goal ${id} not found`);
}
if (existing.status === "active") {
return { goal: existing, changed: false };
}
const activeCountRow = this.db
.prepare("SELECT COUNT(*) as count FROM goals WHERE status = 'active'")
.get() as { count: number } | undefined;
const currentActive = activeCountRow?.count ?? 0;
if (currentActive >= ACTIVE_GOAL_LIMIT) {
throw new ActiveGoalLimitExceededError(ACTIVE_GOAL_LIMIT, currentActive);
}
const updated: Goal = {
...existing,
status: "active",
updatedAt: new Date().toISOString(),
};
this.db
.prepare("UPDATE goals SET status = 'active', updatedAt = ? WHERE id = ?")
.run(updated.updatedAt, id);
return { goal: updated, changed: true };
});
if (!changed) {
return goal;
}
this.db.bumpLastModified();
this.emit("goal:updated", goal);
return goal;
}
public listGoals(filter?: GoalListFilter): Goal[] {
const rows = filter?.status
? this.db
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE status = ? ORDER BY createdAt ASC")
.all(filter.status) as GoalRow[]
: this.db
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals ORDER BY createdAt ASC")
.all() as GoalRow[];
return rows.map((row) => this.toGoal(row));
}
public getGoal(id: string): Goal | null {
const row = this.db
.prepare("SELECT id, title, description, status, createdAt, updatedAt FROM goals WHERE id = ?")
.get(id) as GoalRow | undefined;
if (!row) {
return null;
}
return this.toGoal(row);
}
private toGoal(row: GoalRow): Goal {
return {
id: row.id,
title: row.title,
description: row.description ?? undefined,
status: row.status,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
private generateGoalId(): string {
const timestamp = Date.now().toString(36).toUpperCase();
this.idSequence += 1;
const sequence = this.idSequence.toString(36).toUpperCase().padStart(4, "0");
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `G-${timestamp}-${sequence}-${random}`;
}
}