refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
This commit is contained in:
40
apps/server/db/config.ts
Normal file
40
apps/server/db/config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { findWorkspaceRoot } from '../config/cwrc.js';
|
||||
|
||||
/**
|
||||
* Get the database path.
|
||||
*
|
||||
* - Default: <workspace-root>/.cw/cw.db
|
||||
* - Throws if no .cwrc workspace is found
|
||||
* - Override via CW_DB_PATH environment variable
|
||||
* - For testing, pass ':memory:' as CW_DB_PATH
|
||||
*/
|
||||
export function getDbPath(): string {
|
||||
const envPath = process.env.CW_DB_PATH;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const root = findWorkspaceRoot();
|
||||
if (!root) {
|
||||
throw new Error(
|
||||
'No .cwrc workspace found. Run `cw init` to initialize a workspace.',
|
||||
);
|
||||
}
|
||||
return join(root, '.cw', 'cw.db');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the parent directory for the database file exists.
|
||||
* No-op for in-memory databases.
|
||||
*/
|
||||
export function ensureDbDirectory(dbPath: string): void {
|
||||
// Skip for in-memory database
|
||||
if (dbPath === ':memory:') {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = dirname(dbPath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
42
apps/server/db/ensure-schema.ts
Normal file
42
apps/server/db/ensure-schema.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Database Migration
|
||||
*
|
||||
* Runs drizzle-kit migrations from the drizzle/ directory.
|
||||
* Safe to call on every startup - only applies pending migrations.
|
||||
*/
|
||||
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
import type { DrizzleDatabase } from './index.js';
|
||||
import { createModuleLogger } from '../logger/index.js';
|
||||
|
||||
const log = createModuleLogger('db');
|
||||
|
||||
/**
|
||||
* Resolve the migrations directory relative to the package root.
|
||||
* Works both in development (src/) and after build (dist/).
|
||||
*/
|
||||
function getMigrationsPath(): string {
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
// In dev (tsx): apps/server/db/ — need 3 levels up to workspace root
|
||||
// In dist (tsc): dist/db/ — need 2 levels up to workspace root
|
||||
const upThree = join(currentDir, '..', '..', '..', 'drizzle');
|
||||
if (existsSync(upThree)) return upThree;
|
||||
return join(currentDir, '..', '..', 'drizzle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pending database migrations.
|
||||
*
|
||||
* Uses drizzle-kit's migration system which tracks applied migrations
|
||||
* in a __drizzle_migrations table. Safe to call on every startup.
|
||||
*
|
||||
* @param db - Drizzle database instance
|
||||
*/
|
||||
export function ensureSchema(db: DrizzleDatabase): void {
|
||||
log.info('applying database migrations');
|
||||
migrate(db, { migrationsFolder: getMigrationsPath() });
|
||||
log.info('database migrations complete');
|
||||
}
|
||||
52
apps/server/db/index.ts
Normal file
52
apps/server/db/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
|
||||
import { getDbPath, ensureDbDirectory } from './config.js';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
export type DrizzleDatabase = BetterSQLite3Database<typeof schema>;
|
||||
|
||||
/**
|
||||
* Create a new database connection.
|
||||
*
|
||||
* This is a factory function (not a singleton) to allow multiple instances
|
||||
* for testing with isolated databases.
|
||||
*
|
||||
* @param path - Optional path override. Defaults to getDbPath().
|
||||
* Use ':memory:' for in-memory testing database.
|
||||
* @returns Drizzle database instance with schema
|
||||
*/
|
||||
export function createDatabase(path?: string): DrizzleDatabase {
|
||||
const dbPath = path ?? getDbPath();
|
||||
|
||||
// Ensure directory exists for file-based databases
|
||||
ensureDbDirectory(dbPath);
|
||||
|
||||
// Create SQLite connection
|
||||
const sqlite = new Database(dbPath);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
|
||||
// Enable foreign keys (SQLite has them disabled by default)
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Create Drizzle instance with schema
|
||||
return drizzle(sqlite, { schema });
|
||||
}
|
||||
|
||||
// Re-export config utilities
|
||||
export { getDbPath, ensureDbDirectory } from './config.js';
|
||||
|
||||
// Re-export schema initialization
|
||||
export { ensureSchema } from './ensure-schema.js';
|
||||
|
||||
// Re-export schema and types
|
||||
export * from './schema.js';
|
||||
|
||||
// Re-export repository interfaces (ports)
|
||||
export * from './repositories/index.js';
|
||||
|
||||
// Re-export Drizzle adapters
|
||||
export * from './repositories/drizzle/index.js';
|
||||
61
apps/server/db/repositories/account-repository.ts
Normal file
61
apps/server/db/repositories/account-repository.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Account Repository Port Interface
|
||||
*
|
||||
* Port for Account aggregate operations.
|
||||
* Accounts represent authenticated provider logins (e.g. Claude OAuth accounts)
|
||||
* used for round-robin agent spawning and usage-limit failover.
|
||||
*/
|
||||
|
||||
import type { Account } from '../schema.js';
|
||||
|
||||
export interface CreateAccountData {
|
||||
email: string;
|
||||
provider?: string; // defaults to 'claude'
|
||||
configJson?: string; // .claude.json content
|
||||
credentials?: string; // .credentials.json content
|
||||
}
|
||||
|
||||
export interface AccountRepository {
|
||||
/** Create a new account. Generates id and sets timestamps. */
|
||||
create(data: CreateAccountData): Promise<Account>;
|
||||
|
||||
/** Find an account by its ID. */
|
||||
findById(id: string): Promise<Account | null>;
|
||||
|
||||
/** Find an account by email. */
|
||||
findByEmail(email: string): Promise<Account | null>;
|
||||
|
||||
/** Find all accounts for a given provider. */
|
||||
findByProvider(provider: string): Promise<Account[]>;
|
||||
|
||||
/**
|
||||
* Find the next available (non-exhausted) account for a provider.
|
||||
* Uses round-robin via lastUsedAt ordering (least-recently-used first).
|
||||
* Automatically clears expired exhaustion before querying.
|
||||
*/
|
||||
findNextAvailable(provider: string): Promise<Account | null>;
|
||||
|
||||
/** Mark an account as exhausted until a given time. */
|
||||
markExhausted(id: string, until: Date): Promise<Account>;
|
||||
|
||||
/** Mark an account as available (clear exhaustion). */
|
||||
markAvailable(id: string): Promise<Account>;
|
||||
|
||||
/** Update the lastUsedAt timestamp for an account. */
|
||||
updateLastUsed(id: string): Promise<Account>;
|
||||
|
||||
/** Clear exhaustion for all accounts whose exhaustedUntil has passed. Returns count cleared. */
|
||||
clearExpiredExhaustion(): Promise<number>;
|
||||
|
||||
/** Find all accounts. */
|
||||
findAll(): Promise<Account[]>;
|
||||
|
||||
/** Update stored credentials for an account. */
|
||||
updateCredentials(id: string, credentials: string): Promise<Account>;
|
||||
|
||||
/** Update both configJson and credentials for an account (used by account add upsert). */
|
||||
updateAccountAuth(id: string, configJson: string, credentials: string): Promise<Account>;
|
||||
|
||||
/** Delete an account. Throws if not found. */
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
119
apps/server/db/repositories/agent-repository.ts
Normal file
119
apps/server/db/repositories/agent-repository.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Agent Repository Port Interface
|
||||
*
|
||||
* Port for Agent aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { Agent } from '../schema.js';
|
||||
import type { AgentMode } from '../../agent/types.js';
|
||||
|
||||
/**
|
||||
* Agent status values.
|
||||
*/
|
||||
export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' | 'crashed';
|
||||
|
||||
/**
|
||||
* Data for creating a new agent.
|
||||
* Omits system-managed fields and makes optional fields explicit.
|
||||
*/
|
||||
export interface CreateAgentData {
|
||||
name: string;
|
||||
worktreeId: string;
|
||||
taskId?: string | null;
|
||||
initiativeId?: string | null;
|
||||
sessionId?: string | null;
|
||||
status?: AgentStatus;
|
||||
mode?: AgentMode; // Defaults to 'execute' if not provided
|
||||
provider?: string; // Defaults to 'claude' if not provided
|
||||
accountId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data for updating an existing agent.
|
||||
* All fields optional. System-managed fields (id, createdAt, updatedAt) are excluded.
|
||||
*/
|
||||
export interface UpdateAgentData {
|
||||
name?: string;
|
||||
worktreeId?: string;
|
||||
taskId?: string | null;
|
||||
initiativeId?: string | null;
|
||||
sessionId?: string | null;
|
||||
status?: AgentStatus;
|
||||
mode?: AgentMode;
|
||||
provider?: string;
|
||||
accountId?: string | null;
|
||||
pid?: number | null;
|
||||
exitCode?: number | null;
|
||||
outputFilePath?: string | null;
|
||||
result?: string | null;
|
||||
pendingQuestions?: string | null;
|
||||
userDismissedAt?: Date | null;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Repository Port
|
||||
*
|
||||
* Defines operations for the Agent aggregate.
|
||||
* Enables agent state persistence for session resumption and listing.
|
||||
*/
|
||||
export interface AgentRepository {
|
||||
/**
|
||||
* Create a new agent.
|
||||
* Generates id and sets timestamps automatically.
|
||||
* Name must be unique.
|
||||
*/
|
||||
create(agent: CreateAgentData): Promise<Agent>;
|
||||
|
||||
/**
|
||||
* Find an agent by its ID.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
findById(id: string): Promise<Agent | null>;
|
||||
|
||||
/**
|
||||
* Find an agent by its human-readable name.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
findByName(name: string): Promise<Agent | null>;
|
||||
|
||||
/**
|
||||
* Find an agent by its associated task.
|
||||
* Returns null if no agent is assigned to that task.
|
||||
*/
|
||||
findByTaskId(taskId: string): Promise<Agent | null>;
|
||||
|
||||
/**
|
||||
* Find an agent by its Claude CLI session ID.
|
||||
* Used for session resumption.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
findBySessionId(sessionId: string): Promise<Agent | null>;
|
||||
|
||||
/**
|
||||
* Find all agents.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findAll(): Promise<Agent[]>;
|
||||
|
||||
/**
|
||||
* Find agents by status.
|
||||
* Returns empty array if no agents have that status.
|
||||
*/
|
||||
findByStatus(status: AgentStatus): Promise<Agent[]>;
|
||||
|
||||
/**
|
||||
* Update an agent with partial data.
|
||||
* Only provided fields are updated, others remain unchanged.
|
||||
* Throws if agent not found.
|
||||
* Updates updatedAt timestamp automatically.
|
||||
*/
|
||||
update(id: string, data: UpdateAgentData): Promise<Agent>;
|
||||
|
||||
/**
|
||||
* Delete an agent.
|
||||
* Throws if agent not found.
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
36
apps/server/db/repositories/change-set-repository.ts
Normal file
36
apps/server/db/repositories/change-set-repository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Change Set Repository Port Interface
|
||||
*
|
||||
* Port for ChangeSet aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { ChangeSet, ChangeSetEntry } from '../schema.js';
|
||||
|
||||
export type CreateChangeSetData = {
|
||||
agentId: string | null;
|
||||
agentName: string;
|
||||
initiativeId: string;
|
||||
mode: 'plan' | 'detail' | 'refine';
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
export type CreateChangeSetEntryData = {
|
||||
entityType: 'page' | 'phase' | 'task' | 'phase_dependency';
|
||||
entityId: string;
|
||||
action: 'create' | 'update' | 'delete';
|
||||
previousState?: string | null;
|
||||
newState?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type ChangeSetWithEntries = ChangeSet & { entries: ChangeSetEntry[] };
|
||||
|
||||
export interface ChangeSetRepository {
|
||||
createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet>;
|
||||
findById(id: string): Promise<ChangeSet | null>;
|
||||
findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null>;
|
||||
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
|
||||
findByAgentId(agentId: string): Promise<ChangeSet[]>;
|
||||
markReverted(id: string): Promise<ChangeSet>;
|
||||
}
|
||||
23
apps/server/db/repositories/conversation-repository.ts
Normal file
23
apps/server/db/repositories/conversation-repository.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Conversation Repository Port Interface
|
||||
*
|
||||
* Port for inter-agent conversation persistence operations.
|
||||
*/
|
||||
|
||||
import type { Conversation } from '../schema.js';
|
||||
|
||||
export interface CreateConversationData {
|
||||
fromAgentId: string;
|
||||
toAgentId: string;
|
||||
initiativeId?: string | null;
|
||||
phaseId?: string | null;
|
||||
taskId?: string | null;
|
||||
question: string;
|
||||
}
|
||||
|
||||
export interface ConversationRepository {
|
||||
create(data: CreateConversationData): Promise<Conversation>;
|
||||
findById(id: string): Promise<Conversation | null>;
|
||||
findPendingForAgent(toAgentId: string): Promise<Conversation[]>;
|
||||
answer(id: string, answer: string): Promise<Conversation | null>;
|
||||
}
|
||||
203
apps/server/db/repositories/drizzle/account.ts
Normal file
203
apps/server/db/repositories/drizzle/account.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Drizzle Account Repository Adapter
|
||||
*
|
||||
* Implements AccountRepository interface using Drizzle ORM.
|
||||
* Handles round-robin selection via lastUsedAt ordering
|
||||
* and automatic exhaustion expiry.
|
||||
*/
|
||||
|
||||
import { eq, and, asc, lte } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { accounts, agents, type Account } from '../../schema.js';
|
||||
import type { AccountRepository, CreateAccountData } from '../account-repository.js';
|
||||
|
||||
export class DrizzleAccountRepository implements AccountRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateAccountData): Promise<Account> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(accounts).values({
|
||||
id,
|
||||
email: data.email,
|
||||
provider: data.provider ?? 'claude',
|
||||
configJson: data.configJson ?? null,
|
||||
credentials: data.credentials ?? null,
|
||||
isExhausted: false,
|
||||
exhaustedUntil: null,
|
||||
lastUsedAt: null,
|
||||
sortOrder: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Account | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.id, id))
|
||||
.limit(1);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<Account | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByProvider(provider: string): Promise<Account[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.provider, provider));
|
||||
}
|
||||
|
||||
async findNextAvailable(provider: string): Promise<Account | null> {
|
||||
await this.clearExpiredExhaustion();
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, provider),
|
||||
eq(accounts.isExhausted, false),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(accounts.lastUsedAt))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async markExhausted(id: string, until: Date): Promise<Account> {
|
||||
const now = new Date();
|
||||
const [updated] = await this.db
|
||||
.update(accounts)
|
||||
.set({
|
||||
isExhausted: true,
|
||||
exhaustedUntil: until,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accounts.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Account not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async markAvailable(id: string): Promise<Account> {
|
||||
const now = new Date();
|
||||
const [updated] = await this.db
|
||||
.update(accounts)
|
||||
.set({
|
||||
isExhausted: false,
|
||||
exhaustedUntil: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(accounts.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Account not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateLastUsed(id: string): Promise<Account> {
|
||||
const now = new Date();
|
||||
const [updated] = await this.db
|
||||
.update(accounts)
|
||||
.set({ lastUsedAt: now, updatedAt: now })
|
||||
.where(eq(accounts.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Account not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async clearExpiredExhaustion(): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
const cleared = await this.db
|
||||
.update(accounts)
|
||||
.set({
|
||||
isExhausted: false,
|
||||
exhaustedUntil: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.isExhausted, true),
|
||||
lte(accounts.exhaustedUntil, now),
|
||||
),
|
||||
)
|
||||
.returning({ id: accounts.id });
|
||||
|
||||
return cleared.length;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Account[]> {
|
||||
return this.db.select().from(accounts);
|
||||
}
|
||||
|
||||
async updateCredentials(id: string, credentials: string): Promise<Account> {
|
||||
const now = new Date();
|
||||
const [updated] = await this.db
|
||||
.update(accounts)
|
||||
.set({ credentials, updatedAt: now })
|
||||
.where(eq(accounts.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Account not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateAccountAuth(id: string, configJson: string, credentials: string): Promise<Account> {
|
||||
const now = new Date();
|
||||
const [updated] = await this.db
|
||||
.update(accounts)
|
||||
.set({ configJson, credentials, updatedAt: now })
|
||||
.where(eq(accounts.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Account not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
// Manually nullify agent FK — the migration lacks ON DELETE SET NULL
|
||||
await this.db
|
||||
.update(agents)
|
||||
.set({ accountId: null })
|
||||
.where(eq(agents.accountId, id));
|
||||
|
||||
const [deleted] = await this.db.delete(accounts).where(eq(accounts.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Account not found: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
279
apps/server/db/repositories/drizzle/agent.test.ts
Normal file
279
apps/server/db/repositories/drizzle/agent.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* DrizzleAgentRepository Tests
|
||||
*
|
||||
* Tests for the Agent repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzleAgentRepository } from './agent.js';
|
||||
import { DrizzleTaskRepository } from './task.js';
|
||||
import { DrizzlePhaseRepository } from './phase.js';
|
||||
import { DrizzleInitiativeRepository } from './initiative.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
|
||||
describe('DrizzleAgentRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let agentRepo: DrizzleAgentRepository;
|
||||
let taskRepo: DrizzleTaskRepository;
|
||||
let phaseRepo: DrizzlePhaseRepository;
|
||||
let initiativeRepo: DrizzleInitiativeRepository;
|
||||
let testTaskId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createTestDatabase();
|
||||
agentRepo = new DrizzleAgentRepository(db);
|
||||
taskRepo = new DrizzleTaskRepository(db);
|
||||
phaseRepo = new DrizzlePhaseRepository(db);
|
||||
initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
|
||||
// Create full hierarchy for FK constraint
|
||||
const initiative = await initiativeRepo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
const task = await taskRepo.create({
|
||||
phaseId: phase.id,
|
||||
name: 'Test Task',
|
||||
order: 1,
|
||||
});
|
||||
testTaskId = task.id;
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an agent with generated id and timestamps', async () => {
|
||||
const agent = await agentRepo.create({
|
||||
name: 'gastown',
|
||||
worktreeId: 'worktree-123',
|
||||
taskId: testTaskId,
|
||||
});
|
||||
|
||||
expect(agent.id).toBeDefined();
|
||||
expect(agent.id.length).toBeGreaterThan(0);
|
||||
expect(agent.name).toBe('gastown');
|
||||
expect(agent.worktreeId).toBe('worktree-123');
|
||||
expect(agent.taskId).toBe(testTaskId);
|
||||
expect(agent.sessionId).toBeNull();
|
||||
expect(agent.status).toBe('idle');
|
||||
expect(agent.createdAt).toBeInstanceOf(Date);
|
||||
expect(agent.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should create agent without taskId', async () => {
|
||||
const agent = await agentRepo.create({
|
||||
name: 'standalone',
|
||||
worktreeId: 'worktree-456',
|
||||
});
|
||||
|
||||
expect(agent.taskId).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject duplicate names', async () => {
|
||||
await agentRepo.create({
|
||||
name: 'unique-name',
|
||||
worktreeId: 'worktree-1',
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentRepo.create({
|
||||
name: 'unique-name',
|
||||
worktreeId: 'worktree-2',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return null for non-existent agent', async () => {
|
||||
const result = await agentRepo.findById('non-existent-id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find an existing agent', async () => {
|
||||
const created = await agentRepo.create({
|
||||
name: 'findme',
|
||||
worktreeId: 'worktree-123',
|
||||
});
|
||||
|
||||
const found = await agentRepo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.name).toBe('findme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should return null for non-existent name', async () => {
|
||||
const result = await agentRepo.findByName('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find agent by human-readable name', async () => {
|
||||
await agentRepo.create({
|
||||
name: 'chinatown',
|
||||
worktreeId: 'worktree-123',
|
||||
});
|
||||
|
||||
const found = await agentRepo.findByName('chinatown');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.name).toBe('chinatown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByTaskId', () => {
|
||||
it('should return null when no agent assigned to task', async () => {
|
||||
const result = await agentRepo.findByTaskId(testTaskId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find agent by task', async () => {
|
||||
await agentRepo.create({
|
||||
name: 'task-agent',
|
||||
worktreeId: 'worktree-123',
|
||||
taskId: testTaskId,
|
||||
});
|
||||
|
||||
const found = await agentRepo.findByTaskId(testTaskId);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.taskId).toBe(testTaskId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBySessionId', () => {
|
||||
it('should return null when no agent has session', async () => {
|
||||
const result = await agentRepo.findBySessionId('session-123');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find agent by session ID', async () => {
|
||||
const agent = await agentRepo.create({
|
||||
name: 'session-agent',
|
||||
worktreeId: 'worktree-123',
|
||||
});
|
||||
await agentRepo.update(agent.id, { sessionId: 'session-abc' });
|
||||
|
||||
const found = await agentRepo.findBySessionId('session-abc');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.sessionId).toBe('session-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return empty array when no agents', async () => {
|
||||
const agents = await agentRepo.findAll();
|
||||
expect(agents).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all agents', async () => {
|
||||
await agentRepo.create({ name: 'agent-1', worktreeId: 'wt-1' });
|
||||
await agentRepo.create({ name: 'agent-2', worktreeId: 'wt-2' });
|
||||
await agentRepo.create({ name: 'agent-3', worktreeId: 'wt-3' });
|
||||
|
||||
const agents = await agentRepo.findAll();
|
||||
expect(agents.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByStatus', () => {
|
||||
it('should return empty array when no agents have status', async () => {
|
||||
const agents = await agentRepo.findByStatus('running');
|
||||
expect(agents).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by status correctly', async () => {
|
||||
const agent1 = await agentRepo.create({
|
||||
name: 'idle-agent',
|
||||
worktreeId: 'wt-1',
|
||||
});
|
||||
const agent2 = await agentRepo.create({
|
||||
name: 'running-agent',
|
||||
worktreeId: 'wt-2',
|
||||
});
|
||||
await agentRepo.update(agent2.id, { status: 'running' });
|
||||
|
||||
const idleAgents = await agentRepo.findByStatus('idle');
|
||||
const runningAgents = await agentRepo.findByStatus('running');
|
||||
|
||||
expect(idleAgents.length).toBe(1);
|
||||
expect(idleAgents[0].name).toBe('idle-agent');
|
||||
expect(runningAgents.length).toBe(1);
|
||||
expect(runningAgents[0].name).toBe('running-agent');
|
||||
});
|
||||
|
||||
it('should filter by waiting_for_input status', async () => {
|
||||
const agent = await agentRepo.create({
|
||||
name: 'waiting-agent',
|
||||
worktreeId: 'wt-1',
|
||||
});
|
||||
await agentRepo.update(agent.id, { status: 'waiting_for_input' });
|
||||
|
||||
const waitingAgents = await agentRepo.findByStatus('waiting_for_input');
|
||||
expect(waitingAgents.length).toBe(1);
|
||||
expect(waitingAgents[0].status).toBe('waiting_for_input');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should change status and updatedAt', async () => {
|
||||
const created = await agentRepo.create({
|
||||
name: 'status-test',
|
||||
worktreeId: 'wt-1',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await agentRepo.update(created.id, { status: 'running' });
|
||||
|
||||
expect(updated.status).toBe('running');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
created.updatedAt.getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it('should change sessionId and updatedAt', async () => {
|
||||
const created = await agentRepo.create({
|
||||
name: 'session-test',
|
||||
worktreeId: 'wt-1',
|
||||
});
|
||||
expect(created.sessionId).toBeNull();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await agentRepo.update(created.id, { sessionId: 'new-session-id' });
|
||||
|
||||
expect(updated.sessionId).toBe('new-session-id');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
created.updatedAt.getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for non-existent agent', async () => {
|
||||
await expect(
|
||||
agentRepo.update('non-existent-id', { status: 'running' })
|
||||
).rejects.toThrow('Agent not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete an existing agent', async () => {
|
||||
const created = await agentRepo.create({
|
||||
name: 'to-delete',
|
||||
worktreeId: 'wt-1',
|
||||
});
|
||||
|
||||
await agentRepo.delete(created.id);
|
||||
|
||||
const found = await agentRepo.findById(created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for non-existent agent', async () => {
|
||||
await expect(agentRepo.delete('non-existent-id')).rejects.toThrow(
|
||||
'Agent not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
119
apps/server/db/repositories/drizzle/agent.ts
Normal file
119
apps/server/db/repositories/drizzle/agent.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Drizzle Agent Repository Adapter
|
||||
*
|
||||
* Implements AgentRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { agents, type Agent } from '../../schema.js';
|
||||
import type {
|
||||
AgentRepository,
|
||||
AgentStatus,
|
||||
CreateAgentData,
|
||||
UpdateAgentData,
|
||||
} from '../agent-repository.js';
|
||||
|
||||
/**
|
||||
* Drizzle adapter for AgentRepository.
|
||||
*
|
||||
* Uses dependency injection for database instance,
|
||||
* enabling isolated test databases.
|
||||
*/
|
||||
export class DrizzleAgentRepository implements AgentRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateAgentData): Promise<Agent> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(agents).values({
|
||||
id,
|
||||
name: data.name,
|
||||
taskId: data.taskId ?? null,
|
||||
initiativeId: data.initiativeId ?? null,
|
||||
sessionId: data.sessionId ?? null,
|
||||
worktreeId: data.worktreeId,
|
||||
provider: data.provider ?? 'claude',
|
||||
accountId: data.accountId ?? null,
|
||||
status: data.status ?? 'idle',
|
||||
mode: data.mode ?? 'execute',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.name, name))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByTaskId(taskId: string): Promise<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.taskId, taskId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findBySessionId(sessionId: string): Promise<Agent | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.sessionId, sessionId))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Agent[]> {
|
||||
return this.db.select().from(agents);
|
||||
}
|
||||
|
||||
async findByStatus(status: AgentStatus): Promise<Agent[]> {
|
||||
return this.db.select().from(agents).where(eq(agents.status, status));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateAgentData): Promise<Agent> {
|
||||
const now = new Date();
|
||||
const [updated] = await this.db
|
||||
.update(agents)
|
||||
.set({ ...data, updatedAt: now })
|
||||
.where(eq(agents.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Agent not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [deleted] = await this.db.delete(agents).where(eq(agents.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Agent not found: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
301
apps/server/db/repositories/drizzle/cascade.test.ts
Normal file
301
apps/server/db/repositories/drizzle/cascade.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Cascade Delete Tests
|
||||
*
|
||||
* Tests that cascade deletes work correctly through the repository layer.
|
||||
* Verifies the SQLite foreign key cascade behavior configured in schema.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { DrizzleInitiativeRepository } from './initiative.js';
|
||||
import { DrizzlePhaseRepository } from './phase.js';
|
||||
import { DrizzleTaskRepository } from './task.js';
|
||||
import { DrizzlePageRepository } from './page.js';
|
||||
import { DrizzleProjectRepository } from './project.js';
|
||||
import { DrizzleChangeSetRepository } from './change-set.js';
|
||||
import { DrizzleAgentRepository } from './agent.js';
|
||||
import { DrizzleConversationRepository } from './conversation.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import { changeSets } from '../../schema.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
|
||||
describe('Cascade Deletes', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let initiativeRepo: DrizzleInitiativeRepository;
|
||||
let phaseRepo: DrizzlePhaseRepository;
|
||||
let taskRepo: DrizzleTaskRepository;
|
||||
let pageRepo: DrizzlePageRepository;
|
||||
let projectRepo: DrizzleProjectRepository;
|
||||
let changeSetRepo: DrizzleChangeSetRepository;
|
||||
let agentRepo: DrizzleAgentRepository;
|
||||
let conversationRepo: DrizzleConversationRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
phaseRepo = new DrizzlePhaseRepository(db);
|
||||
taskRepo = new DrizzleTaskRepository(db);
|
||||
pageRepo = new DrizzlePageRepository(db);
|
||||
projectRepo = new DrizzleProjectRepository(db);
|
||||
changeSetRepo = new DrizzleChangeSetRepository(db);
|
||||
agentRepo = new DrizzleAgentRepository(db);
|
||||
conversationRepo = new DrizzleConversationRepository(db);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a full hierarchy for testing.
|
||||
* Uses parent tasks (detail category) to group child tasks.
|
||||
*/
|
||||
async function createFullHierarchy() {
|
||||
const initiative = await initiativeRepo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
|
||||
// Create parent (detail) tasks that group child tasks
|
||||
const parentTask1 = await taskRepo.create({
|
||||
phaseId: phase1.id,
|
||||
initiativeId: initiative.id,
|
||||
name: 'Parent Task 1-1',
|
||||
category: 'detail',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const parentTask2 = await taskRepo.create({
|
||||
phaseId: phase1.id,
|
||||
initiativeId: initiative.id,
|
||||
name: 'Parent Task 1-2',
|
||||
category: 'detail',
|
||||
order: 2,
|
||||
});
|
||||
|
||||
const parentTask3 = await taskRepo.create({
|
||||
phaseId: phase2.id,
|
||||
initiativeId: initiative.id,
|
||||
name: 'Parent Task 2-1',
|
||||
category: 'detail',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
// Create child tasks under parent tasks
|
||||
const task1 = await taskRepo.create({
|
||||
parentTaskId: parentTask1.id,
|
||||
phaseId: phase1.id,
|
||||
initiativeId: initiative.id,
|
||||
name: 'Task 1-1-1',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const task2 = await taskRepo.create({
|
||||
parentTaskId: parentTask1.id,
|
||||
phaseId: phase1.id,
|
||||
initiativeId: initiative.id,
|
||||
name: 'Task 1-1-2',
|
||||
order: 2,
|
||||
});
|
||||
|
||||
const task3 = await taskRepo.create({
|
||||
parentTaskId: parentTask2.id,
|
||||
phaseId: phase1.id,
|
||||
initiativeId: initiative.id,
|
||||
name: 'Task 1-2-1',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const task4 = await taskRepo.create({
|
||||
parentTaskId: parentTask3.id,
|
||||
phaseId: phase2.id,
|
||||
initiativeId: initiative.id,
|
||||
name: 'Task 2-1-1',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
// Create a page for the initiative
|
||||
const page = await pageRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
parentPageId: null,
|
||||
title: 'Root Page',
|
||||
content: null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
|
||||
// Create a project and link it via junction table
|
||||
const project = await projectRepo.create({
|
||||
name: 'test-project',
|
||||
url: 'https://github.com/test/test-project.git',
|
||||
});
|
||||
await projectRepo.setInitiativeProjects(initiative.id, [project.id]);
|
||||
|
||||
// Create two agents (need two for conversations, and one for changeSet FK)
|
||||
const agent1 = await agentRepo.create({
|
||||
name: 'agent-1',
|
||||
worktreeId: 'wt-1',
|
||||
initiativeId: initiative.id,
|
||||
});
|
||||
const agent2 = await agentRepo.create({
|
||||
name: 'agent-2',
|
||||
worktreeId: 'wt-2',
|
||||
initiativeId: initiative.id,
|
||||
});
|
||||
|
||||
// Insert change set directly (createWithEntries uses async tx, incompatible with better-sqlite3 sync driver)
|
||||
const changeSetId = nanoid();
|
||||
await db.insert(changeSets).values({
|
||||
id: changeSetId,
|
||||
agentId: agent1.id,
|
||||
agentName: agent1.name,
|
||||
initiativeId: initiative.id,
|
||||
mode: 'plan',
|
||||
status: 'applied',
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const changeSet = (await changeSetRepo.findById(changeSetId))!;
|
||||
|
||||
// Create a conversation between agents with initiative context
|
||||
const conversation = await conversationRepo.create({
|
||||
fromAgentId: agent1.id,
|
||||
toAgentId: agent2.id,
|
||||
initiativeId: initiative.id,
|
||||
question: 'Test question',
|
||||
});
|
||||
|
||||
return {
|
||||
initiative,
|
||||
phases: { phase1, phase2 },
|
||||
parentTasks: { parentTask1, parentTask2, parentTask3 },
|
||||
tasks: { task1, task2, task3, task4 },
|
||||
page,
|
||||
project,
|
||||
changeSet,
|
||||
agents: { agent1, agent2 },
|
||||
conversation,
|
||||
};
|
||||
}
|
||||
|
||||
describe('delete initiative', () => {
|
||||
it('should cascade delete all phases, tasks, pages, junction rows, and change sets', async () => {
|
||||
const { initiative, phases, parentTasks, tasks, page, project, changeSet } =
|
||||
await createFullHierarchy();
|
||||
|
||||
// Verify everything exists
|
||||
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
|
||||
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
|
||||
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask1.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task1.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task2.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
|
||||
expect(await pageRepo.findById(page.id)).not.toBeNull();
|
||||
expect(await changeSetRepo.findById(changeSet.id)).not.toBeNull();
|
||||
const linkedProjects = await projectRepo.findProjectsByInitiativeId(initiative.id);
|
||||
expect(linkedProjects).toHaveLength(1);
|
||||
|
||||
// Delete initiative
|
||||
await initiativeRepo.delete(initiative.id);
|
||||
|
||||
// Verify cascade deletes — all gone
|
||||
expect(await initiativeRepo.findById(initiative.id)).toBeNull();
|
||||
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
|
||||
expect(await phaseRepo.findById(phases.phase2.id)).toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask3.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task4.id)).toBeNull();
|
||||
expect(await pageRepo.findById(page.id)).toBeNull();
|
||||
expect(await changeSetRepo.findById(changeSet.id)).toBeNull();
|
||||
|
||||
// Junction row gone but project itself survives
|
||||
const remainingLinks = await projectRepo.findProjectsByInitiativeId(initiative.id);
|
||||
expect(remainingLinks).toHaveLength(0);
|
||||
expect(await projectRepo.findById(project.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should set null on agents and conversations (not cascade)', async () => {
|
||||
const { initiative, agents, conversation } = await createFullHierarchy();
|
||||
|
||||
// Verify agents are linked
|
||||
const a1Before = await agentRepo.findById(agents.agent1.id);
|
||||
expect(a1Before!.initiativeId).toBe(initiative.id);
|
||||
|
||||
// Delete initiative
|
||||
await initiativeRepo.delete(initiative.id);
|
||||
|
||||
// Agents survive with initiativeId set to null
|
||||
const a1After = await agentRepo.findById(agents.agent1.id);
|
||||
expect(a1After).not.toBeNull();
|
||||
expect(a1After!.initiativeId).toBeNull();
|
||||
|
||||
const a2After = await agentRepo.findById(agents.agent2.id);
|
||||
expect(a2After).not.toBeNull();
|
||||
expect(a2After!.initiativeId).toBeNull();
|
||||
|
||||
// Conversation survives with initiativeId set to null
|
||||
const convAfter = await conversationRepo.findById(conversation.id);
|
||||
expect(convAfter).not.toBeNull();
|
||||
expect(convAfter!.initiativeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete phase', () => {
|
||||
it('should cascade delete tasks under that phase only', async () => {
|
||||
const { initiative, phases, parentTasks, tasks } = await createFullHierarchy();
|
||||
|
||||
// Delete phase 1
|
||||
await phaseRepo.delete(phases.phase1.id);
|
||||
|
||||
// Initiative still exists
|
||||
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
|
||||
|
||||
// Phase 1 and its tasks are gone
|
||||
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask2.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
|
||||
|
||||
// Phase 2 and its tasks still exist
|
||||
expect(await phaseRepo.findById(phases.phase2.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete parent task', () => {
|
||||
it('should cascade delete child tasks under that parent only', async () => {
|
||||
const { phases, parentTasks, tasks } = await createFullHierarchy();
|
||||
|
||||
// Delete parent task 1
|
||||
await taskRepo.delete(parentTasks.parentTask1.id);
|
||||
|
||||
// Phase still exists
|
||||
expect(await phaseRepo.findById(phases.phase1.id)).not.toBeNull();
|
||||
|
||||
// Parent task 1 and its children are gone
|
||||
expect(await taskRepo.findById(parentTasks.parentTask1.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task1.id)).toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
|
||||
|
||||
// Other parent tasks and their children still exist
|
||||
expect(await taskRepo.findById(parentTasks.parentTask2.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(parentTasks.parentTask3.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
|
||||
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
110
apps/server/db/repositories/drizzle/change-set.ts
Normal file
110
apps/server/db/repositories/drizzle/change-set.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Drizzle Change Set Repository Adapter
|
||||
*
|
||||
* Implements ChangeSetRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, desc, asc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
|
||||
import type {
|
||||
ChangeSetRepository,
|
||||
CreateChangeSetData,
|
||||
CreateChangeSetEntryData,
|
||||
ChangeSetWithEntries,
|
||||
} from '../change-set-repository.js';
|
||||
|
||||
export class DrizzleChangeSetRepository implements ChangeSetRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async createWithEntries(data: CreateChangeSetData, entries: CreateChangeSetEntryData[]): Promise<ChangeSet> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
// Use transaction for atomicity
|
||||
return this.db.transaction(async (tx) => {
|
||||
const [created] = await tx.insert(changeSets).values({
|
||||
id,
|
||||
agentId: data.agentId,
|
||||
agentName: data.agentName,
|
||||
initiativeId: data.initiativeId,
|
||||
mode: data.mode,
|
||||
summary: data.summary ?? null,
|
||||
status: 'applied',
|
||||
createdAt: now,
|
||||
}).returning();
|
||||
|
||||
if (entries.length > 0) {
|
||||
const entryRows = entries.map((e, i) => ({
|
||||
id: nanoid(),
|
||||
changeSetId: id,
|
||||
entityType: e.entityType,
|
||||
entityId: e.entityId,
|
||||
action: e.action,
|
||||
previousState: e.previousState ?? null,
|
||||
newState: e.newState ?? null,
|
||||
sortOrder: e.sortOrder ?? i,
|
||||
createdAt: now,
|
||||
}));
|
||||
|
||||
await tx.insert(changeSetEntries).values(entryRows);
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ChangeSet | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(changeSets)
|
||||
.where(eq(changeSets.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByIdWithEntries(id: string): Promise<ChangeSetWithEntries | null> {
|
||||
const cs = await this.findById(id);
|
||||
if (!cs) return null;
|
||||
|
||||
const entries = await this.db
|
||||
.select()
|
||||
.from(changeSetEntries)
|
||||
.where(eq(changeSetEntries.changeSetId, id))
|
||||
.orderBy(asc(changeSetEntries.sortOrder));
|
||||
|
||||
return { ...cs, entries };
|
||||
}
|
||||
|
||||
async findByInitiativeId(initiativeId: string): Promise<ChangeSet[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(changeSets)
|
||||
.where(eq(changeSets.initiativeId, initiativeId))
|
||||
.orderBy(desc(changeSets.createdAt));
|
||||
}
|
||||
|
||||
async findByAgentId(agentId: string): Promise<ChangeSet[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(changeSets)
|
||||
.where(eq(changeSets.agentId, agentId))
|
||||
.orderBy(desc(changeSets.createdAt));
|
||||
}
|
||||
|
||||
async markReverted(id: string): Promise<ChangeSet> {
|
||||
const [updated] = await this.db
|
||||
.update(changeSets)
|
||||
.set({ status: 'reverted', revertedAt: new Date() })
|
||||
.where(eq(changeSets.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`ChangeSet not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
67
apps/server/db/repositories/drizzle/conversation.ts
Normal file
67
apps/server/db/repositories/drizzle/conversation.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Drizzle Conversation Repository Adapter
|
||||
*
|
||||
* Implements ConversationRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, and, asc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { conversations, type Conversation } from '../../schema.js';
|
||||
import type { ConversationRepository, CreateConversationData } from '../conversation-repository.js';
|
||||
|
||||
export class DrizzleConversationRepository implements ConversationRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateConversationData): Promise<Conversation> {
|
||||
const now = new Date();
|
||||
const id = nanoid();
|
||||
await this.db.insert(conversations).values({
|
||||
id,
|
||||
fromAgentId: data.fromAgentId,
|
||||
toAgentId: data.toAgentId,
|
||||
initiativeId: data.initiativeId ?? null,
|
||||
phaseId: data.phaseId ?? null,
|
||||
taskId: data.taskId ?? null,
|
||||
question: data.question,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return this.findById(id) as Promise<Conversation>;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Conversation | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(eq(conversations.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findPendingForAgent(toAgentId: string): Promise<Conversation[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(
|
||||
and(
|
||||
eq(conversations.toAgentId, toAgentId),
|
||||
eq(conversations.status, 'pending' as 'pending' | 'answered'),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(conversations.createdAt));
|
||||
}
|
||||
|
||||
async answer(id: string, answer: string): Promise<Conversation | null> {
|
||||
await this.db
|
||||
.update(conversations)
|
||||
.set({
|
||||
answer,
|
||||
status: 'answered' as 'pending' | 'answered',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(conversations.id, id));
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
18
apps/server/db/repositories/drizzle/index.ts
Normal file
18
apps/server/db/repositories/drizzle/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Drizzle Repository Adapters
|
||||
*
|
||||
* Re-exports all Drizzle implementations of repository interfaces.
|
||||
* These are the ADAPTERS for the repository PORTS.
|
||||
*/
|
||||
|
||||
export { DrizzleInitiativeRepository } from './initiative.js';
|
||||
export { DrizzlePhaseRepository } from './phase.js';
|
||||
export { DrizzleTaskRepository } from './task.js';
|
||||
export { DrizzleAgentRepository } from './agent.js';
|
||||
export { DrizzleMessageRepository } from './message.js';
|
||||
export { DrizzlePageRepository } from './page.js';
|
||||
export { DrizzleProjectRepository } from './project.js';
|
||||
export { DrizzleAccountRepository } from './account.js';
|
||||
export { DrizzleChangeSetRepository } from './change-set.js';
|
||||
export { DrizzleLogChunkRepository } from './log-chunk.js';
|
||||
export { DrizzleConversationRepository } from './conversation.js';
|
||||
150
apps/server/db/repositories/drizzle/initiative.test.ts
Normal file
150
apps/server/db/repositories/drizzle/initiative.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* DrizzleInitiativeRepository Tests
|
||||
*
|
||||
* Tests for the Initiative repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzleInitiativeRepository } from './initiative.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
|
||||
describe('DrizzleInitiativeRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let repo: DrizzleInitiativeRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDatabase();
|
||||
repo = new DrizzleInitiativeRepository(db);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an initiative with generated id and timestamps', async () => {
|
||||
const initiative = await repo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
|
||||
expect(initiative.id).toBeDefined();
|
||||
expect(initiative.id.length).toBeGreaterThan(0);
|
||||
expect(initiative.name).toBe('Test Initiative');
|
||||
expect(initiative.status).toBe('active');
|
||||
expect(initiative.createdAt).toBeInstanceOf(Date);
|
||||
expect(initiative.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should use provided status', async () => {
|
||||
const initiative = await repo.create({
|
||||
name: 'Completed Initiative',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(initiative.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return null for non-existent initiative', async () => {
|
||||
const result = await repo.findById('non-existent-id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find an existing initiative', async () => {
|
||||
const created = await repo.create({
|
||||
name: 'Find Me',
|
||||
});
|
||||
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.name).toBe('Find Me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return empty array initially', async () => {
|
||||
const all = await repo.findAll();
|
||||
expect(all).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all initiatives', async () => {
|
||||
await repo.create({ name: 'Initiative 1' });
|
||||
await repo.create({ name: 'Initiative 2' });
|
||||
await repo.create({ name: 'Initiative 3' });
|
||||
|
||||
const all = await repo.findAll();
|
||||
expect(all.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update fields and updatedAt', async () => {
|
||||
const created = await repo.create({
|
||||
name: 'Original Name',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Small delay to ensure updatedAt differs
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await repo.update(created.id, {
|
||||
name: 'Updated Name',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
expect(updated.status).toBe('completed');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
|
||||
});
|
||||
|
||||
it('should throw for non-existent initiative', async () => {
|
||||
await expect(
|
||||
repo.update('non-existent-id', { name: 'New Name' })
|
||||
).rejects.toThrow('Initiative not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete an existing initiative', async () => {
|
||||
const created = await repo.create({ name: 'To Delete' });
|
||||
|
||||
await repo.delete(created.id);
|
||||
|
||||
const found = await repo.findById(created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for non-existent initiative', async () => {
|
||||
await expect(repo.delete('non-existent-id')).rejects.toThrow(
|
||||
'Initiative not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByStatus', () => {
|
||||
it('should return empty array for no matches', async () => {
|
||||
await repo.create({ name: 'Active 1', status: 'active' });
|
||||
|
||||
const completed = await repo.findByStatus('completed');
|
||||
expect(completed).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
await repo.create({ name: 'Active 1', status: 'active' });
|
||||
await repo.create({ name: 'Active 2', status: 'active' });
|
||||
await repo.create({ name: 'Completed', status: 'completed' });
|
||||
await repo.create({ name: 'Archived', status: 'archived' });
|
||||
|
||||
const active = await repo.findByStatus('active');
|
||||
expect(active).toHaveLength(2);
|
||||
expect(active.every((i) => i.status === 'active')).toBe(true);
|
||||
|
||||
const completed = await repo.findByStatus('completed');
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0].name).toBe('Completed');
|
||||
|
||||
const archived = await repo.findByStatus('archived');
|
||||
expect(archived).toHaveLength(1);
|
||||
expect(archived[0].name).toBe('Archived');
|
||||
});
|
||||
});
|
||||
});
|
||||
87
apps/server/db/repositories/drizzle/initiative.ts
Normal file
87
apps/server/db/repositories/drizzle/initiative.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Drizzle Initiative Repository Adapter
|
||||
*
|
||||
* Implements InitiativeRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { agents, initiatives, type Initiative } from '../../schema.js';
|
||||
import type {
|
||||
InitiativeRepository,
|
||||
CreateInitiativeData,
|
||||
UpdateInitiativeData,
|
||||
} from '../initiative-repository.js';
|
||||
|
||||
/**
|
||||
* Drizzle adapter for InitiativeRepository.
|
||||
*
|
||||
* Uses dependency injection for database instance,
|
||||
* enabling isolated test databases.
|
||||
*/
|
||||
export class DrizzleInitiativeRepository implements InitiativeRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateInitiativeData): Promise<Initiative> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(initiatives).values({
|
||||
id,
|
||||
...data,
|
||||
status: data.status ?? 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Initiative | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(initiatives)
|
||||
.where(eq(initiatives.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Initiative[]> {
|
||||
return this.db.select().from(initiatives);
|
||||
}
|
||||
|
||||
async findByStatus(status: 'active' | 'completed' | 'archived'): Promise<Initiative[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(initiatives)
|
||||
.where(eq(initiatives.status, status));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateInitiativeData): Promise<Initiative> {
|
||||
const [updated] = await this.db
|
||||
.update(initiatives)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(initiatives.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Initiative not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
// Detach agents before deleting — agents.initiative_id FK may lack ON DELETE SET NULL
|
||||
// in databases that haven't applied migration 0025 yet.
|
||||
await this.db.update(agents).set({ initiativeId: null }).where(eq(agents.initiativeId, id));
|
||||
|
||||
const [deleted] = await this.db.delete(initiatives).where(eq(initiatives.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Initiative not found: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
apps/server/db/repositories/drizzle/log-chunk.ts
Normal file
58
apps/server/db/repositories/drizzle/log-chunk.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Drizzle Log Chunk Repository Adapter
|
||||
*
|
||||
* Implements LogChunkRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, asc, max } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { agentLogChunks } from '../../schema.js';
|
||||
import type { LogChunkRepository } from '../log-chunk-repository.js';
|
||||
|
||||
export class DrizzleLogChunkRepository implements LogChunkRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async insertChunk(data: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
sessionNumber: number;
|
||||
content: string;
|
||||
}): Promise<void> {
|
||||
await this.db.insert(agentLogChunks).values({
|
||||
id: nanoid(),
|
||||
agentId: data.agentId,
|
||||
agentName: data.agentName,
|
||||
sessionNumber: data.sessionNumber,
|
||||
content: data.content,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async findByAgentId(agentId: string): Promise<{ content: string; sessionNumber: number; createdAt: Date }[]> {
|
||||
return this.db
|
||||
.select({
|
||||
content: agentLogChunks.content,
|
||||
sessionNumber: agentLogChunks.sessionNumber,
|
||||
createdAt: agentLogChunks.createdAt,
|
||||
})
|
||||
.from(agentLogChunks)
|
||||
.where(eq(agentLogChunks.agentId, agentId))
|
||||
.orderBy(asc(agentLogChunks.createdAt));
|
||||
}
|
||||
|
||||
async deleteByAgentId(agentId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(agentLogChunks)
|
||||
.where(eq(agentLogChunks.agentId, agentId));
|
||||
}
|
||||
|
||||
async getSessionCount(agentId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ maxSession: max(agentLogChunks.sessionNumber) })
|
||||
.from(agentLogChunks)
|
||||
.where(eq(agentLogChunks.agentId, agentId));
|
||||
|
||||
return result[0]?.maxSession ?? 0;
|
||||
}
|
||||
}
|
||||
456
apps/server/db/repositories/drizzle/message.test.ts
Normal file
456
apps/server/db/repositories/drizzle/message.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* DrizzleMessageRepository Tests
|
||||
*
|
||||
* Tests for the Message repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzleMessageRepository } from './message.js';
|
||||
import { DrizzleAgentRepository } from './agent.js';
|
||||
import { DrizzleTaskRepository } from './task.js';
|
||||
import { DrizzlePhaseRepository } from './phase.js';
|
||||
import { DrizzleInitiativeRepository } from './initiative.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
|
||||
describe('DrizzleMessageRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let messageRepo: DrizzleMessageRepository;
|
||||
let agentRepo: DrizzleAgentRepository;
|
||||
let testAgentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createTestDatabase();
|
||||
messageRepo = new DrizzleMessageRepository(db);
|
||||
agentRepo = new DrizzleAgentRepository(db);
|
||||
|
||||
// Create required hierarchy for agent FK
|
||||
const taskRepo = new DrizzleTaskRepository(db);
|
||||
const phaseRepo = new DrizzlePhaseRepository(db);
|
||||
const initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
|
||||
const initiative = await initiativeRepo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
const task = await taskRepo.create({
|
||||
phaseId: phase.id,
|
||||
name: 'Test Task',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
// Create a test agent
|
||||
const agent = await agentRepo.create({
|
||||
name: 'test-agent',
|
||||
worktreeId: 'worktree-123',
|
||||
taskId: task.id,
|
||||
});
|
||||
testAgentId = agent.id;
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create agent→user message (question)', async () => {
|
||||
const message = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'question',
|
||||
content: 'Do you want to proceed with deployment?',
|
||||
requiresResponse: true,
|
||||
});
|
||||
|
||||
expect(message.id).toBeDefined();
|
||||
expect(message.id.length).toBeGreaterThan(0);
|
||||
expect(message.senderType).toBe('agent');
|
||||
expect(message.senderId).toBe(testAgentId);
|
||||
expect(message.recipientType).toBe('user');
|
||||
expect(message.recipientId).toBeNull();
|
||||
expect(message.type).toBe('question');
|
||||
expect(message.content).toBe('Do you want to proceed with deployment?');
|
||||
expect(message.requiresResponse).toBe(true);
|
||||
expect(message.status).toBe('pending');
|
||||
expect(message.parentMessageId).toBeNull();
|
||||
expect(message.createdAt).toBeInstanceOf(Date);
|
||||
expect(message.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should create agent→user message (notification, requiresResponse=false)', async () => {
|
||||
const message = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'info',
|
||||
content: 'Build completed successfully.',
|
||||
requiresResponse: false,
|
||||
});
|
||||
|
||||
expect(message.type).toBe('info');
|
||||
expect(message.requiresResponse).toBe(false);
|
||||
expect(message.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should create user→agent response', async () => {
|
||||
// First create the question
|
||||
const question = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'question',
|
||||
content: 'Which database?',
|
||||
requiresResponse: true,
|
||||
});
|
||||
|
||||
// Then create user response
|
||||
const response = await messageRepo.create({
|
||||
senderType: 'user',
|
||||
recipientType: 'agent',
|
||||
recipientId: testAgentId,
|
||||
type: 'response',
|
||||
content: 'Use PostgreSQL',
|
||||
parentMessageId: question.id,
|
||||
});
|
||||
|
||||
expect(response.senderType).toBe('user');
|
||||
expect(response.senderId).toBeNull();
|
||||
expect(response.recipientType).toBe('agent');
|
||||
expect(response.recipientId).toBe(testAgentId);
|
||||
expect(response.parentMessageId).toBe(question.id);
|
||||
});
|
||||
|
||||
it('should default type to info', async () => {
|
||||
const message = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Status update',
|
||||
});
|
||||
|
||||
expect(message.type).toBe('info');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return null for non-existent message', async () => {
|
||||
const result = await messageRepo.findById('non-existent-id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find an existing message', async () => {
|
||||
const created = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Test message',
|
||||
});
|
||||
|
||||
const found = await messageRepo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.content).toBe('Test message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBySender', () => {
|
||||
it('should find messages by agent sender', async () => {
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Message 1',
|
||||
});
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Message 2',
|
||||
});
|
||||
|
||||
const messages = await messageRepo.findBySender('agent', testAgentId);
|
||||
expect(messages.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should find messages by user sender', async () => {
|
||||
await messageRepo.create({
|
||||
senderType: 'user',
|
||||
recipientType: 'agent',
|
||||
recipientId: testAgentId,
|
||||
content: 'User message 1',
|
||||
});
|
||||
await messageRepo.create({
|
||||
senderType: 'user',
|
||||
recipientType: 'agent',
|
||||
recipientId: testAgentId,
|
||||
content: 'User message 2',
|
||||
});
|
||||
|
||||
const messages = await messageRepo.findBySender('user');
|
||||
expect(messages.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty array when no messages from sender', async () => {
|
||||
const messages = await messageRepo.findBySender('agent', 'nonexistent-id');
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByRecipient', () => {
|
||||
it('should find messages by user recipient', async () => {
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'For user 1',
|
||||
});
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'For user 2',
|
||||
});
|
||||
|
||||
const messages = await messageRepo.findByRecipient('user');
|
||||
expect(messages.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should find messages by agent recipient', async () => {
|
||||
await messageRepo.create({
|
||||
senderType: 'user',
|
||||
recipientType: 'agent',
|
||||
recipientId: testAgentId,
|
||||
content: 'For agent',
|
||||
});
|
||||
|
||||
const messages = await messageRepo.findByRecipient('agent', testAgentId);
|
||||
expect(messages.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return empty array when no messages for recipient', async () => {
|
||||
const messages = await messageRepo.findByRecipient('agent', 'nonexistent-id');
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPendingForUser', () => {
|
||||
it('should return only user-targeted pending messages', async () => {
|
||||
// Create pending message for user
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Pending for user',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Create read message for user (should not be returned)
|
||||
const readMessage = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Already read',
|
||||
});
|
||||
await messageRepo.update(readMessage.id, { status: 'read' });
|
||||
|
||||
// Create pending message for agent (should not be returned)
|
||||
await messageRepo.create({
|
||||
senderType: 'user',
|
||||
recipientType: 'agent',
|
||||
recipientId: testAgentId,
|
||||
content: 'For agent not user',
|
||||
});
|
||||
|
||||
const pending = await messageRepo.findPendingForUser();
|
||||
expect(pending.length).toBe(1);
|
||||
expect(pending[0].content).toBe('Pending for user');
|
||||
});
|
||||
|
||||
it('should return empty array when no pending messages for user', async () => {
|
||||
const pending = await messageRepo.findPendingForUser();
|
||||
expect(pending).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRequiringResponse', () => {
|
||||
it('should return only messages needing response', async () => {
|
||||
// Create message requiring response
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'question',
|
||||
content: 'Requires answer',
|
||||
requiresResponse: true,
|
||||
});
|
||||
|
||||
// Create message not requiring response
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'info',
|
||||
content: 'Just info',
|
||||
requiresResponse: false,
|
||||
});
|
||||
|
||||
// Create message that required response but was responded
|
||||
const responded = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'question',
|
||||
content: 'Already answered',
|
||||
requiresResponse: true,
|
||||
});
|
||||
await messageRepo.update(responded.id, { status: 'responded' });
|
||||
|
||||
const requiring = await messageRepo.findRequiringResponse();
|
||||
expect(requiring.length).toBe(1);
|
||||
expect(requiring[0].content).toBe('Requires answer');
|
||||
});
|
||||
|
||||
it('should return empty array when no messages require response', async () => {
|
||||
const requiring = await messageRepo.findRequiringResponse();
|
||||
expect(requiring).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReplies (message threading)', () => {
|
||||
it('should find all replies to a parent message', async () => {
|
||||
// Create original question
|
||||
const question = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'question',
|
||||
content: 'Original question',
|
||||
requiresResponse: true,
|
||||
});
|
||||
|
||||
// Create two replies
|
||||
await messageRepo.create({
|
||||
senderType: 'user',
|
||||
recipientType: 'agent',
|
||||
recipientId: testAgentId,
|
||||
type: 'response',
|
||||
content: 'First reply',
|
||||
parentMessageId: question.id,
|
||||
});
|
||||
await messageRepo.create({
|
||||
senderType: 'user',
|
||||
recipientType: 'agent',
|
||||
recipientId: testAgentId,
|
||||
type: 'response',
|
||||
content: 'Second reply',
|
||||
parentMessageId: question.id,
|
||||
});
|
||||
|
||||
// Create unrelated message (should not be in replies)
|
||||
await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Unrelated message',
|
||||
});
|
||||
|
||||
const replies = await messageRepo.findReplies(question.id);
|
||||
expect(replies.length).toBe(2);
|
||||
expect(replies.every((r) => r.parentMessageId === question.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array when message has no replies', async () => {
|
||||
const message = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'No replies',
|
||||
});
|
||||
|
||||
const replies = await messageRepo.findReplies(message.id);
|
||||
expect(replies).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update status flow', () => {
|
||||
it('should update status: pending → read → responded', async () => {
|
||||
const message = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
type: 'question',
|
||||
content: 'Status flow test',
|
||||
requiresResponse: true,
|
||||
});
|
||||
expect(message.status).toBe('pending');
|
||||
|
||||
// Wait to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Update to read
|
||||
const readMessage = await messageRepo.update(message.id, { status: 'read' });
|
||||
expect(readMessage.status).toBe('read');
|
||||
expect(readMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(message.updatedAt.getTime());
|
||||
|
||||
// Wait again
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
// Update to responded
|
||||
const respondedMessage = await messageRepo.update(readMessage.id, {
|
||||
status: 'responded',
|
||||
});
|
||||
expect(respondedMessage.status).toBe('responded');
|
||||
expect(respondedMessage.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
readMessage.updatedAt.getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should throw for non-existent message', async () => {
|
||||
await expect(
|
||||
messageRepo.update('non-existent-id', { status: 'read' })
|
||||
).rejects.toThrow('Message not found');
|
||||
});
|
||||
|
||||
it('should update content and updatedAt', async () => {
|
||||
const created = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'Original content',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await messageRepo.update(created.id, {
|
||||
content: 'Updated content',
|
||||
});
|
||||
|
||||
expect(updated.content).toBe('Updated content');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete an existing message', async () => {
|
||||
const created = await messageRepo.create({
|
||||
senderType: 'agent',
|
||||
senderId: testAgentId,
|
||||
recipientType: 'user',
|
||||
content: 'To delete',
|
||||
});
|
||||
|
||||
await messageRepo.delete(created.id);
|
||||
|
||||
const found = await messageRepo.findById(created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for non-existent message', async () => {
|
||||
await expect(messageRepo.delete('non-existent-id')).rejects.toThrow(
|
||||
'Message not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
apps/server/db/repositories/drizzle/message.ts
Normal file
138
apps/server/db/repositories/drizzle/message.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Drizzle Message Repository Adapter
|
||||
*
|
||||
* Implements MessageRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { messages, type Message } from '../../schema.js';
|
||||
import type {
|
||||
MessageRepository,
|
||||
CreateMessageData,
|
||||
UpdateMessageData,
|
||||
MessageParticipantType,
|
||||
} from '../message-repository.js';
|
||||
|
||||
/**
|
||||
* Drizzle adapter for MessageRepository.
|
||||
*
|
||||
* Uses dependency injection for database instance,
|
||||
* enabling isolated test databases.
|
||||
*/
|
||||
export class DrizzleMessageRepository implements MessageRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateMessageData): Promise<Message> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(messages).values({
|
||||
id,
|
||||
senderType: data.senderType,
|
||||
senderId: data.senderId ?? null,
|
||||
recipientType: data.recipientType,
|
||||
recipientId: data.recipientId ?? null,
|
||||
type: data.type ?? 'info',
|
||||
content: data.content,
|
||||
requiresResponse: data.requiresResponse ?? false,
|
||||
status: data.status ?? 'pending',
|
||||
parentMessageId: data.parentMessageId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Message | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findBySender(type: MessageParticipantType, id?: string): Promise<Message[]> {
|
||||
if (id) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(and(eq(messages.senderType, type), eq(messages.senderId, id)))
|
||||
.orderBy(desc(messages.createdAt));
|
||||
}
|
||||
|
||||
// For user sender (no id), find where senderType='user' and senderId is null
|
||||
return this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.senderType, type))
|
||||
.orderBy(desc(messages.createdAt));
|
||||
}
|
||||
|
||||
async findByRecipient(type: MessageParticipantType, id?: string): Promise<Message[]> {
|
||||
if (id) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(and(eq(messages.recipientType, type), eq(messages.recipientId, id)))
|
||||
.orderBy(desc(messages.createdAt));
|
||||
}
|
||||
|
||||
// For user recipient (no id), find where recipientType='user' and recipientId is null
|
||||
return this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.recipientType, type))
|
||||
.orderBy(desc(messages.createdAt));
|
||||
}
|
||||
|
||||
async findPendingForUser(): Promise<Message[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(and(eq(messages.recipientType, 'user'), eq(messages.status, 'pending')))
|
||||
.orderBy(desc(messages.createdAt));
|
||||
}
|
||||
|
||||
async findRequiringResponse(): Promise<Message[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(and(eq(messages.requiresResponse, true), eq(messages.status, 'pending')))
|
||||
.orderBy(desc(messages.createdAt));
|
||||
}
|
||||
|
||||
async findReplies(parentMessageId: string): Promise<Message[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.parentMessageId, parentMessageId))
|
||||
.orderBy(desc(messages.createdAt));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateMessageData): Promise<Message> {
|
||||
const [updated] = await this.db
|
||||
.update(messages)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(messages.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Message not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [deleted] = await this.db.delete(messages).where(eq(messages.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Message not found: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
117
apps/server/db/repositories/drizzle/page.ts
Normal file
117
apps/server/db/repositories/drizzle/page.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Drizzle Page Repository Adapter
|
||||
*
|
||||
* Implements PageRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, isNull, and, asc, inArray } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { pages, type Page } from '../../schema.js';
|
||||
import type {
|
||||
PageRepository,
|
||||
CreatePageData,
|
||||
UpdatePageData,
|
||||
} from '../page-repository.js';
|
||||
|
||||
export class DrizzlePageRepository implements PageRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreatePageData): Promise<Page> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(pages).values({
|
||||
id,
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Page | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(eq(pages.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<Page[]> {
|
||||
if (ids.length === 0) return [];
|
||||
return this.db
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(inArray(pages.id, ids));
|
||||
}
|
||||
|
||||
async findByInitiativeId(initiativeId: string): Promise<Page[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(eq(pages.initiativeId, initiativeId))
|
||||
.orderBy(asc(pages.sortOrder));
|
||||
}
|
||||
|
||||
async findByParentPageId(parentPageId: string): Promise<Page[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(eq(pages.parentPageId, parentPageId))
|
||||
.orderBy(asc(pages.sortOrder));
|
||||
}
|
||||
|
||||
async findRootPage(initiativeId: string): Promise<Page | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(pages)
|
||||
.where(
|
||||
and(
|
||||
eq(pages.initiativeId, initiativeId),
|
||||
isNull(pages.parentPageId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async getOrCreateRootPage(initiativeId: string): Promise<Page> {
|
||||
const existing = await this.findRootPage(initiativeId);
|
||||
if (existing) return existing;
|
||||
|
||||
return this.create({
|
||||
initiativeId,
|
||||
parentPageId: null,
|
||||
title: 'Untitled',
|
||||
content: null,
|
||||
sortOrder: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdatePageData): Promise<Page> {
|
||||
const [updated] = await this.db
|
||||
.update(pages)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(pages.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Page not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [deleted] = await this.db.delete(pages).where(eq(pages.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Page not found: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
408
apps/server/db/repositories/drizzle/phase.test.ts
Normal file
408
apps/server/db/repositories/drizzle/phase.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* DrizzlePhaseRepository Tests
|
||||
*
|
||||
* Tests for the Phase repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzlePhaseRepository } from './phase.js';
|
||||
import { DrizzleInitiativeRepository } from './initiative.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
|
||||
describe('DrizzlePhaseRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let phaseRepo: DrizzlePhaseRepository;
|
||||
let initiativeRepo: DrizzleInitiativeRepository;
|
||||
let testInitiativeId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createTestDatabase();
|
||||
phaseRepo = new DrizzlePhaseRepository(db);
|
||||
initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
|
||||
// Create a test initiative for FK constraint
|
||||
const initiative = await initiativeRepo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
testInitiativeId = initiative.id;
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a phase with generated id and timestamps', async () => {
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
|
||||
expect(phase.id).toBeDefined();
|
||||
expect(phase.id.length).toBeGreaterThan(0);
|
||||
expect(phase.initiativeId).toBe(testInitiativeId);
|
||||
expect(phase.name).toBe('Test Phase');
|
||||
expect(phase.status).toBe('pending');
|
||||
expect(phase.createdAt).toBeInstanceOf(Date);
|
||||
expect(phase.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should throw for invalid initiativeId (FK constraint)', async () => {
|
||||
await expect(
|
||||
phaseRepo.create({
|
||||
initiativeId: 'invalid-initiative-id',
|
||||
name: 'Invalid Phase',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return null for non-existent phase', async () => {
|
||||
const result = await phaseRepo.findById('non-existent-id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find an existing phase', async () => {
|
||||
const created = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Find Me',
|
||||
});
|
||||
|
||||
const found = await phaseRepo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.name).toBe('Find Me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByInitiativeId', () => {
|
||||
it('should return empty array for initiative with no phases', async () => {
|
||||
const phases = await phaseRepo.findByInitiativeId(testInitiativeId);
|
||||
expect(phases).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return only matching phases ordered by createdAt', async () => {
|
||||
await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase A',
|
||||
});
|
||||
await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase B',
|
||||
});
|
||||
await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase C',
|
||||
});
|
||||
|
||||
// Create another initiative with phases
|
||||
const otherInitiative = await initiativeRepo.create({
|
||||
name: 'Other Initiative',
|
||||
});
|
||||
await phaseRepo.create({
|
||||
initiativeId: otherInitiative.id,
|
||||
name: 'Other Phase',
|
||||
});
|
||||
|
||||
const phases = await phaseRepo.findByInitiativeId(testInitiativeId);
|
||||
expect(phases.length).toBe(3);
|
||||
expect(phases[0].name).toBe('Phase A');
|
||||
expect(phases[1].name).toBe('Phase B');
|
||||
expect(phases[2].name).toBe('Phase C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update fields and updatedAt', async () => {
|
||||
const created = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Original Name',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await phaseRepo.update(created.id, {
|
||||
name: 'Updated Name',
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
expect(updated.status).toBe('in_progress');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
|
||||
});
|
||||
|
||||
it('should throw for non-existent phase', async () => {
|
||||
await expect(
|
||||
phaseRepo.update('non-existent-id', { name: 'New Name' })
|
||||
).rejects.toThrow('Phase not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete an existing phase', async () => {
|
||||
const created = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'To Delete',
|
||||
});
|
||||
|
||||
await phaseRepo.delete(created.id);
|
||||
|
||||
const found = await phaseRepo.findById(created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for non-existent phase', async () => {
|
||||
await expect(phaseRepo.delete('non-existent-id')).rejects.toThrow(
|
||||
'Phase not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Phase Dependency Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe('createDependency', () => {
|
||||
it('should create dependency between two phases', async () => {
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
|
||||
await phaseRepo.createDependency(phase2.id, phase1.id);
|
||||
|
||||
const deps = await phaseRepo.getDependencies(phase2.id);
|
||||
expect(deps).toContain(phase1.id);
|
||||
});
|
||||
|
||||
it('should throw if phase does not exist', async () => {
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
|
||||
// Creating dependency with non-existent phase should throw (FK constraint)
|
||||
await expect(
|
||||
phaseRepo.createDependency('non-existent-phase', phase1.id)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should allow multiple dependencies for same phase', async () => {
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
const phase3 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 3',
|
||||
});
|
||||
|
||||
// Phase 3 depends on both Phase 1 and Phase 2
|
||||
await phaseRepo.createDependency(phase3.id, phase1.id);
|
||||
await phaseRepo.createDependency(phase3.id, phase2.id);
|
||||
|
||||
const deps = await phaseRepo.getDependencies(phase3.id);
|
||||
expect(deps.length).toBe(2);
|
||||
expect(deps).toContain(phase1.id);
|
||||
expect(deps).toContain(phase2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDependencies', () => {
|
||||
it('should return empty array for phase with no dependencies', async () => {
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
|
||||
const deps = await phaseRepo.getDependencies(phase.id);
|
||||
expect(deps).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return dependency IDs for phase with dependencies', async () => {
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
const phase3 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 3',
|
||||
});
|
||||
|
||||
// Phase 3 depends on Phase 1 and Phase 2
|
||||
await phaseRepo.createDependency(phase3.id, phase1.id);
|
||||
await phaseRepo.createDependency(phase3.id, phase2.id);
|
||||
|
||||
const deps = await phaseRepo.getDependencies(phase3.id);
|
||||
expect(deps.length).toBe(2);
|
||||
expect(deps).toContain(phase1.id);
|
||||
expect(deps).toContain(phase2.id);
|
||||
});
|
||||
|
||||
it('should return only direct dependencies (not transitive)', async () => {
|
||||
// Phase 1 -> Phase 2 -> Phase 3 (linear chain)
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
const phase3 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 3',
|
||||
});
|
||||
|
||||
// Phase 2 depends on Phase 1
|
||||
await phaseRepo.createDependency(phase2.id, phase1.id);
|
||||
// Phase 3 depends on Phase 2
|
||||
await phaseRepo.createDependency(phase3.id, phase2.id);
|
||||
|
||||
// Phase 3's dependencies should only include Phase 2, not Phase 1
|
||||
const depsPhase3 = await phaseRepo.getDependencies(phase3.id);
|
||||
expect(depsPhase3.length).toBe(1);
|
||||
expect(depsPhase3).toContain(phase2.id);
|
||||
expect(depsPhase3).not.toContain(phase1.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDependents', () => {
|
||||
it('should return empty array for phase with no dependents', async () => {
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
|
||||
const dependents = await phaseRepo.getDependents(phase.id);
|
||||
expect(dependents).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return dependent phase IDs', async () => {
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
const phase3 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 3',
|
||||
});
|
||||
|
||||
// Phase 2 and Phase 3 both depend on Phase 1
|
||||
await phaseRepo.createDependency(phase2.id, phase1.id);
|
||||
await phaseRepo.createDependency(phase3.id, phase1.id);
|
||||
|
||||
const dependents = await phaseRepo.getDependents(phase1.id);
|
||||
expect(dependents.length).toBe(2);
|
||||
expect(dependents).toContain(phase2.id);
|
||||
expect(dependents).toContain(phase3.id);
|
||||
});
|
||||
|
||||
it('should return only direct dependents', async () => {
|
||||
// Phase 1 -> Phase 2 -> Phase 3 (linear chain)
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
const phase3 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 3',
|
||||
});
|
||||
|
||||
// Phase 2 depends on Phase 1
|
||||
await phaseRepo.createDependency(phase2.id, phase1.id);
|
||||
// Phase 3 depends on Phase 2
|
||||
await phaseRepo.createDependency(phase3.id, phase2.id);
|
||||
|
||||
// Phase 1's dependents should only include Phase 2, not Phase 3
|
||||
const dependentsPhase1 = await phaseRepo.getDependents(phase1.id);
|
||||
expect(dependentsPhase1.length).toBe(1);
|
||||
expect(dependentsPhase1).toContain(phase2.id);
|
||||
expect(dependentsPhase1).not.toContain(phase3.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDependenciesByInitiativeId', () => {
|
||||
it('should return empty array for initiative with no dependencies', async () => {
|
||||
await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
|
||||
const deps = await phaseRepo.findDependenciesByInitiativeId(testInitiativeId);
|
||||
expect(deps).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all dependency edges for an initiative', async () => {
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
const phase3 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 3',
|
||||
});
|
||||
|
||||
await phaseRepo.createDependency(phase2.id, phase1.id);
|
||||
await phaseRepo.createDependency(phase3.id, phase1.id);
|
||||
await phaseRepo.createDependency(phase3.id, phase2.id);
|
||||
|
||||
const deps = await phaseRepo.findDependenciesByInitiativeId(testInitiativeId);
|
||||
expect(deps.length).toBe(3);
|
||||
expect(deps).toContainEqual({ phaseId: phase2.id, dependsOnPhaseId: phase1.id });
|
||||
expect(deps).toContainEqual({ phaseId: phase3.id, dependsOnPhaseId: phase1.id });
|
||||
expect(deps).toContainEqual({ phaseId: phase3.id, dependsOnPhaseId: phase2.id });
|
||||
});
|
||||
|
||||
it('should not return dependencies from other initiatives', async () => {
|
||||
const phase1 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 1',
|
||||
});
|
||||
const phase2 = await phaseRepo.create({
|
||||
initiativeId: testInitiativeId,
|
||||
name: 'Phase 2',
|
||||
});
|
||||
await phaseRepo.createDependency(phase2.id, phase1.id);
|
||||
|
||||
const otherInitiative = await initiativeRepo.create({ name: 'Other' });
|
||||
const otherPhase1 = await phaseRepo.create({
|
||||
initiativeId: otherInitiative.id,
|
||||
name: 'Other Phase 1',
|
||||
});
|
||||
const otherPhase2 = await phaseRepo.create({
|
||||
initiativeId: otherInitiative.id,
|
||||
name: 'Other Phase 2',
|
||||
});
|
||||
await phaseRepo.createDependency(otherPhase2.id, otherPhase1.id);
|
||||
|
||||
const deps = await phaseRepo.findDependenciesByInitiativeId(testInitiativeId);
|
||||
expect(deps.length).toBe(1);
|
||||
expect(deps[0].phaseId).toBe(phase2.id);
|
||||
expect(deps[0].dependsOnPhaseId).toBe(phase1.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
130
apps/server/db/repositories/drizzle/phase.ts
Normal file
130
apps/server/db/repositories/drizzle/phase.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Drizzle Phase Repository Adapter
|
||||
*
|
||||
* Implements PhaseRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, asc, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { phases, phaseDependencies, type Phase } from '../../schema.js';
|
||||
import type {
|
||||
PhaseRepository,
|
||||
CreatePhaseData,
|
||||
UpdatePhaseData,
|
||||
} from '../phase-repository.js';
|
||||
|
||||
/**
|
||||
* Drizzle adapter for PhaseRepository.
|
||||
*
|
||||
* Uses dependency injection for database instance,
|
||||
* enabling isolated test databases.
|
||||
*/
|
||||
export class DrizzlePhaseRepository implements PhaseRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreatePhaseData): Promise<Phase> {
|
||||
const { id: providedId, ...rest } = data;
|
||||
const id = providedId ?? nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(phases).values({
|
||||
id,
|
||||
...rest,
|
||||
status: data.status ?? 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Phase | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(phases)
|
||||
.where(eq(phases.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByInitiativeId(initiativeId: string): Promise<Phase[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(phases)
|
||||
.where(eq(phases.initiativeId, initiativeId))
|
||||
.orderBy(asc(phases.createdAt));
|
||||
}
|
||||
|
||||
async findDependenciesByInitiativeId(initiativeId: string): Promise<Array<{ phaseId: string; dependsOnPhaseId: string }>> {
|
||||
return this.db
|
||||
.select({ phaseId: phaseDependencies.phaseId, dependsOnPhaseId: phaseDependencies.dependsOnPhaseId })
|
||||
.from(phaseDependencies)
|
||||
.innerJoin(phases, eq(phaseDependencies.phaseId, phases.id))
|
||||
.where(eq(phases.initiativeId, initiativeId));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdatePhaseData): Promise<Phase> {
|
||||
const [updated] = await this.db
|
||||
.update(phases)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(phases.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Phase not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [deleted] = await this.db.delete(phases).where(eq(phases.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Phase not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createDependency(phaseId: string, dependsOnPhaseId: string): Promise<void> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await this.db.insert(phaseDependencies).values({
|
||||
id,
|
||||
phaseId,
|
||||
dependsOnPhaseId,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
async getDependencies(phaseId: string): Promise<string[]> {
|
||||
const result = await this.db
|
||||
.select({ dependsOnPhaseId: phaseDependencies.dependsOnPhaseId })
|
||||
.from(phaseDependencies)
|
||||
.where(eq(phaseDependencies.phaseId, phaseId));
|
||||
|
||||
return result.map((row) => row.dependsOnPhaseId);
|
||||
}
|
||||
|
||||
async getDependents(phaseId: string): Promise<string[]> {
|
||||
const result = await this.db
|
||||
.select({ phaseId: phaseDependencies.phaseId })
|
||||
.from(phaseDependencies)
|
||||
.where(eq(phaseDependencies.dependsOnPhaseId, phaseId));
|
||||
|
||||
return result.map((row) => row.phaseId);
|
||||
}
|
||||
|
||||
async removeDependency(phaseId: string, dependsOnPhaseId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(phaseDependencies)
|
||||
.where(
|
||||
and(
|
||||
eq(phaseDependencies.phaseId, phaseId),
|
||||
eq(phaseDependencies.dependsOnPhaseId, dependsOnPhaseId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
154
apps/server/db/repositories/drizzle/project.ts
Normal file
154
apps/server/db/repositories/drizzle/project.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Drizzle Project Repository Adapter
|
||||
*
|
||||
* Implements ProjectRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { projects, initiativeProjects, type Project } from '../../schema.js';
|
||||
import type {
|
||||
ProjectRepository,
|
||||
CreateProjectData,
|
||||
UpdateProjectData,
|
||||
} from '../project-repository.js';
|
||||
|
||||
export class DrizzleProjectRepository implements ProjectRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateProjectData): Promise<Project> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(projects).values({
|
||||
id,
|
||||
...data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Project | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Project | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.name, name))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Project[]> {
|
||||
return this.db.select().from(projects);
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateProjectData): Promise<Project> {
|
||||
const [updated] = await this.db
|
||||
.update(projects)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(projects.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Project not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [deleted] = await this.db.delete(projects).where(eq(projects.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Project not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Junction ops
|
||||
|
||||
async addProjectToInitiative(initiativeId: string, projectId: string): Promise<void> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await this.db.insert(initiativeProjects).values({
|
||||
id,
|
||||
initiativeId,
|
||||
projectId,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
async removeProjectFromInitiative(initiativeId: string, projectId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(initiativeProjects)
|
||||
.where(
|
||||
and(
|
||||
eq(initiativeProjects.initiativeId, initiativeId),
|
||||
eq(initiativeProjects.projectId, projectId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findProjectsByInitiativeId(initiativeId: string): Promise<Project[]> {
|
||||
const rows = await this.db
|
||||
.select({ project: projects })
|
||||
.from(initiativeProjects)
|
||||
.innerJoin(projects, eq(initiativeProjects.projectId, projects.id))
|
||||
.where(eq(initiativeProjects.initiativeId, initiativeId));
|
||||
|
||||
return rows.map((r) => r.project);
|
||||
}
|
||||
|
||||
async setInitiativeProjects(initiativeId: string, projectIds: string[]): Promise<void> {
|
||||
// Get current associations
|
||||
const currentRows = await this.db
|
||||
.select({ projectId: initiativeProjects.projectId })
|
||||
.from(initiativeProjects)
|
||||
.where(eq(initiativeProjects.initiativeId, initiativeId));
|
||||
|
||||
const currentIds = new Set(currentRows.map((r) => r.projectId));
|
||||
const desiredIds = new Set(projectIds);
|
||||
|
||||
// Compute diff
|
||||
const toRemove = [...currentIds].filter((id) => !desiredIds.has(id));
|
||||
const toAdd = [...desiredIds].filter((id) => !currentIds.has(id));
|
||||
|
||||
// Remove
|
||||
if (toRemove.length > 0) {
|
||||
await this.db
|
||||
.delete(initiativeProjects)
|
||||
.where(
|
||||
and(
|
||||
eq(initiativeProjects.initiativeId, initiativeId),
|
||||
inArray(initiativeProjects.projectId, toRemove),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add
|
||||
if (toAdd.length > 0) {
|
||||
const now = new Date();
|
||||
await this.db.insert(initiativeProjects).values(
|
||||
toAdd.map((projectId) => ({
|
||||
id: nanoid(),
|
||||
initiativeId,
|
||||
projectId,
|
||||
createdAt: now,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
199
apps/server/db/repositories/drizzle/task.test.ts
Normal file
199
apps/server/db/repositories/drizzle/task.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* DrizzleTaskRepository Tests
|
||||
*
|
||||
* Tests for the Task repository adapter.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { DrizzleTaskRepository } from './task.js';
|
||||
import { DrizzlePhaseRepository } from './phase.js';
|
||||
import { DrizzleInitiativeRepository } from './initiative.js';
|
||||
import { createTestDatabase } from './test-helpers.js';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
|
||||
describe('DrizzleTaskRepository', () => {
|
||||
let db: DrizzleDatabase;
|
||||
let taskRepo: DrizzleTaskRepository;
|
||||
|
||||
let phaseRepo: DrizzlePhaseRepository;
|
||||
let initiativeRepo: DrizzleInitiativeRepository;
|
||||
let testPhaseId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = createTestDatabase();
|
||||
taskRepo = new DrizzleTaskRepository(db);
|
||||
|
||||
phaseRepo = new DrizzlePhaseRepository(db);
|
||||
initiativeRepo = new DrizzleInitiativeRepository(db);
|
||||
|
||||
// Create full hierarchy for FK constraint
|
||||
const initiative = await initiativeRepo.create({
|
||||
name: 'Test Initiative',
|
||||
});
|
||||
const phase = await phaseRepo.create({
|
||||
initiativeId: initiative.id,
|
||||
name: 'Test Phase',
|
||||
});
|
||||
testPhaseId = phase.id;
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a task with generated id and timestamps', async () => {
|
||||
const task = await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Test Task',
|
||||
description: 'A test task',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
expect(task.id).toBeDefined();
|
||||
expect(task.id.length).toBeGreaterThan(0);
|
||||
expect(task.phaseId).toBe(testPhaseId);
|
||||
expect(task.name).toBe('Test Task');
|
||||
expect(task.type).toBe('auto');
|
||||
expect(task.priority).toBe('medium');
|
||||
expect(task.status).toBe('pending');
|
||||
expect(task.order).toBe(1);
|
||||
expect(task.createdAt).toBeInstanceOf(Date);
|
||||
expect(task.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should throw for invalid phaseId (FK constraint)', async () => {
|
||||
await expect(
|
||||
taskRepo.create({
|
||||
phaseId: 'invalid-phase-id',
|
||||
name: 'Invalid Task',
|
||||
order: 1,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should accept custom type and priority', async () => {
|
||||
const task = await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Checkpoint Task',
|
||||
type: 'checkpoint:human-verify',
|
||||
priority: 'high',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
expect(task.type).toBe('checkpoint:human-verify');
|
||||
expect(task.priority).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return null for non-existent task', async () => {
|
||||
const result = await taskRepo.findById('non-existent-id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find an existing task', async () => {
|
||||
const created = await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Find Me',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const found = await taskRepo.findById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found!.id).toBe(created.id);
|
||||
expect(found!.name).toBe('Find Me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByPhaseId', () => {
|
||||
it('should return empty array for phase with no tasks', async () => {
|
||||
const tasks = await taskRepo.findByPhaseId(testPhaseId);
|
||||
expect(tasks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return only matching tasks ordered by order field', async () => {
|
||||
// Create tasks out of order
|
||||
await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task 3',
|
||||
order: 3,
|
||||
});
|
||||
await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task 1',
|
||||
order: 1,
|
||||
});
|
||||
await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Task 2',
|
||||
order: 2,
|
||||
});
|
||||
|
||||
const tasks = await taskRepo.findByPhaseId(testPhaseId);
|
||||
expect(tasks.length).toBe(3);
|
||||
expect(tasks[0].name).toBe('Task 1');
|
||||
expect(tasks[1].name).toBe('Task 2');
|
||||
expect(tasks[2].name).toBe('Task 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update status correctly', async () => {
|
||||
const created = await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Status Test',
|
||||
status: 'pending',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
const updated = await taskRepo.update(created.id, {
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
expect(updated.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('should update fields and updatedAt', async () => {
|
||||
const created = await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'Original Name',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await taskRepo.update(created.id, {
|
||||
name: 'Updated Name',
|
||||
priority: 'low',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
expect(updated.priority).toBe('low');
|
||||
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.updatedAt.getTime());
|
||||
});
|
||||
|
||||
it('should throw for non-existent task', async () => {
|
||||
await expect(
|
||||
taskRepo.update('non-existent-id', { name: 'New Name' })
|
||||
).rejects.toThrow('Task not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete an existing task', async () => {
|
||||
const created = await taskRepo.create({
|
||||
phaseId: testPhaseId,
|
||||
name: 'To Delete',
|
||||
order: 1,
|
||||
});
|
||||
|
||||
await taskRepo.delete(created.id);
|
||||
|
||||
const found = await taskRepo.findById(created.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw for non-existent task', async () => {
|
||||
await expect(taskRepo.delete('non-existent-id')).rejects.toThrow(
|
||||
'Task not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
apps/server/db/repositories/drizzle/task.ts
Normal file
142
apps/server/db/repositories/drizzle/task.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Drizzle Task Repository Adapter
|
||||
*
|
||||
* Implements TaskRepository interface using Drizzle ORM.
|
||||
*/
|
||||
|
||||
import { eq, asc, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { tasks, taskDependencies, type Task } from '../../schema.js';
|
||||
import type {
|
||||
TaskRepository,
|
||||
CreateTaskData,
|
||||
UpdateTaskData,
|
||||
PendingApprovalFilters,
|
||||
} from '../task-repository.js';
|
||||
|
||||
/**
|
||||
* Drizzle adapter for TaskRepository.
|
||||
*
|
||||
* Uses dependency injection for database instance,
|
||||
* enabling isolated test databases.
|
||||
*/
|
||||
export class DrizzleTaskRepository implements TaskRepository {
|
||||
constructor(private db: DrizzleDatabase) {}
|
||||
|
||||
async create(data: CreateTaskData): Promise<Task> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
const [created] = await this.db.insert(tasks).values({
|
||||
id,
|
||||
...data,
|
||||
type: data.type ?? 'auto',
|
||||
category: data.category ?? 'execute',
|
||||
priority: data.priority ?? 'medium',
|
||||
status: data.status ?? 'pending',
|
||||
order: data.order ?? 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Task | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.id, id))
|
||||
.limit(1);
|
||||
|
||||
return result[0] ?? null;
|
||||
}
|
||||
|
||||
async findByParentTaskId(parentTaskId: string): Promise<Task[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.parentTaskId, parentTaskId))
|
||||
.orderBy(asc(tasks.order));
|
||||
}
|
||||
|
||||
async findByInitiativeId(initiativeId: string): Promise<Task[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.initiativeId, initiativeId))
|
||||
.orderBy(asc(tasks.order));
|
||||
}
|
||||
|
||||
async findByPhaseId(phaseId: string): Promise<Task[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(eq(tasks.phaseId, phaseId))
|
||||
.orderBy(asc(tasks.order));
|
||||
}
|
||||
|
||||
async findPendingApproval(filters?: PendingApprovalFilters): Promise<Task[]> {
|
||||
const conditions = [eq(tasks.status, 'pending_approval')];
|
||||
|
||||
if (filters?.initiativeId) {
|
||||
conditions.push(eq(tasks.initiativeId, filters.initiativeId));
|
||||
}
|
||||
if (filters?.phaseId) {
|
||||
conditions.push(eq(tasks.phaseId, filters.phaseId));
|
||||
}
|
||||
if (filters?.category) {
|
||||
conditions.push(eq(tasks.category, filters.category));
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(tasks.createdAt));
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateTaskData): Promise<Task> {
|
||||
const [updated] = await this.db
|
||||
.update(tasks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(tasks.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const [deleted] = await this.db.delete(tasks).where(eq(tasks.id, id)).returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Task not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createDependency(taskId: string, dependsOnTaskId: string): Promise<void> {
|
||||
const id = nanoid();
|
||||
const now = new Date();
|
||||
|
||||
await this.db.insert(taskDependencies).values({
|
||||
id,
|
||||
taskId,
|
||||
dependsOnTaskId,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
async getDependencies(taskId: string): Promise<string[]> {
|
||||
const deps = await this.db
|
||||
.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
|
||||
.from(taskDependencies)
|
||||
.where(eq(taskDependencies.taskId, taskId));
|
||||
|
||||
return deps.map((d) => d.dependsOnTaskId);
|
||||
}
|
||||
}
|
||||
30
apps/server/db/repositories/drizzle/test-helpers.ts
Normal file
30
apps/server/db/repositories/drizzle/test-helpers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Test helpers for repository tests.
|
||||
*
|
||||
* Provides utilities for setting up in-memory test databases
|
||||
* with schema applied.
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import type { DrizzleDatabase } from '../../index.js';
|
||||
import { ensureSchema } from '../../ensure-schema.js';
|
||||
import * as schema from '../../schema.js';
|
||||
|
||||
/**
|
||||
* Create an in-memory test database with schema applied.
|
||||
* Returns a fresh Drizzle instance for each call.
|
||||
*/
|
||||
export function createTestDatabase(): DrizzleDatabase {
|
||||
const sqlite = new Database(':memory:');
|
||||
|
||||
// Enable foreign keys
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
// Create all tables
|
||||
ensureSchema(db);
|
||||
|
||||
return db;
|
||||
}
|
||||
74
apps/server/db/repositories/index.ts
Normal file
74
apps/server/db/repositories/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Repository Port Interfaces
|
||||
*
|
||||
* Re-exports all repository port interfaces.
|
||||
* These are the PORTS in hexagonal architecture.
|
||||
* Implementations in ./drizzle/ are ADAPTERS.
|
||||
*/
|
||||
|
||||
export type {
|
||||
InitiativeRepository,
|
||||
CreateInitiativeData,
|
||||
UpdateInitiativeData,
|
||||
} from './initiative-repository.js';
|
||||
|
||||
export type {
|
||||
PhaseRepository,
|
||||
CreatePhaseData,
|
||||
UpdatePhaseData,
|
||||
} from './phase-repository.js';
|
||||
|
||||
export type {
|
||||
TaskRepository,
|
||||
CreateTaskData,
|
||||
UpdateTaskData,
|
||||
PendingApprovalFilters,
|
||||
} from './task-repository.js';
|
||||
|
||||
export type {
|
||||
AgentRepository,
|
||||
AgentStatus,
|
||||
CreateAgentData,
|
||||
} from './agent-repository.js';
|
||||
|
||||
export type {
|
||||
MessageRepository,
|
||||
MessageParticipantType,
|
||||
MessageType,
|
||||
MessageStatus,
|
||||
CreateMessageData,
|
||||
UpdateMessageData,
|
||||
} from './message-repository.js';
|
||||
|
||||
export type {
|
||||
PageRepository,
|
||||
CreatePageData,
|
||||
UpdatePageData,
|
||||
} from './page-repository.js';
|
||||
|
||||
export type {
|
||||
ProjectRepository,
|
||||
CreateProjectData,
|
||||
UpdateProjectData,
|
||||
} from './project-repository.js';
|
||||
|
||||
export type {
|
||||
AccountRepository,
|
||||
CreateAccountData,
|
||||
} from './account-repository.js';
|
||||
|
||||
export type {
|
||||
ChangeSetRepository,
|
||||
CreateChangeSetData,
|
||||
CreateChangeSetEntryData,
|
||||
ChangeSetWithEntries,
|
||||
} from './change-set-repository.js';
|
||||
|
||||
export type {
|
||||
LogChunkRepository,
|
||||
} from './log-chunk-repository.js';
|
||||
|
||||
export type {
|
||||
ConversationRepository,
|
||||
CreateConversationData,
|
||||
} from './conversation-repository.js';
|
||||
66
apps/server/db/repositories/initiative-repository.ts
Normal file
66
apps/server/db/repositories/initiative-repository.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Initiative Repository Port Interface
|
||||
*
|
||||
* Port for Initiative aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { Initiative, NewInitiative } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Data for creating a new initiative.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreateInitiativeData = Omit<NewInitiative, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* Data for updating an initiative.
|
||||
* Partial of creation data - all fields optional.
|
||||
*/
|
||||
export type UpdateInitiativeData = Partial<CreateInitiativeData>;
|
||||
|
||||
/**
|
||||
* Initiative Repository Port
|
||||
*
|
||||
* Defines operations for the Initiative aggregate.
|
||||
* Only knows about initiatives - no knowledge of child entities.
|
||||
*/
|
||||
export interface InitiativeRepository {
|
||||
/**
|
||||
* Create a new initiative.
|
||||
* Generates id and sets timestamps automatically.
|
||||
*/
|
||||
create(data: CreateInitiativeData): Promise<Initiative>;
|
||||
|
||||
/**
|
||||
* Find an initiative by its ID.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
findById(id: string): Promise<Initiative | null>;
|
||||
|
||||
/**
|
||||
* Find all initiatives.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findAll(): Promise<Initiative[]>;
|
||||
|
||||
/**
|
||||
* Find all initiatives with a specific status.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findByStatus(status: 'active' | 'completed' | 'archived'): Promise<Initiative[]>;
|
||||
|
||||
/**
|
||||
* Update an initiative.
|
||||
* Throws if initiative not found.
|
||||
* Updates updatedAt timestamp automatically.
|
||||
*/
|
||||
update(id: string, data: UpdateInitiativeData): Promise<Initiative>;
|
||||
|
||||
/**
|
||||
* Delete an initiative.
|
||||
* Throws if initiative not found.
|
||||
* Cascades to child phases, plans, tasks via FK constraints.
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
23
apps/server/db/repositories/log-chunk-repository.ts
Normal file
23
apps/server/db/repositories/log-chunk-repository.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Log Chunk Repository Port Interface
|
||||
*
|
||||
* Port for agent log chunk persistence operations.
|
||||
* No FK to agents — chunks survive agent deletion.
|
||||
*/
|
||||
|
||||
import type { AgentLogChunk } from '../schema.js';
|
||||
|
||||
export interface LogChunkRepository {
|
||||
insertChunk(data: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
sessionNumber: number;
|
||||
content: string;
|
||||
}): Promise<void>;
|
||||
|
||||
findByAgentId(agentId: string): Promise<Pick<AgentLogChunk, 'content' | 'sessionNumber' | 'createdAt'>[]>;
|
||||
|
||||
deleteByAgentId(agentId: string): Promise<void>;
|
||||
|
||||
getSessionCount(agentId: string): Promise<number>;
|
||||
}
|
||||
118
apps/server/db/repositories/message-repository.ts
Normal file
118
apps/server/db/repositories/message-repository.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Message Repository Port Interface
|
||||
*
|
||||
* Port for Message aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*
|
||||
* Messages persist agent questions for users to query and respond later.
|
||||
* Supports threading via parentMessageId for response linking.
|
||||
*/
|
||||
|
||||
import type { Message } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Message sender/recipient type.
|
||||
*/
|
||||
export type MessageParticipantType = 'agent' | 'user';
|
||||
|
||||
/**
|
||||
* Message type.
|
||||
*/
|
||||
export type MessageType = 'question' | 'info' | 'error' | 'response';
|
||||
|
||||
/**
|
||||
* Message status.
|
||||
*/
|
||||
export type MessageStatus = 'pending' | 'read' | 'responded';
|
||||
|
||||
/**
|
||||
* Data for creating a new message.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export interface CreateMessageData {
|
||||
senderType: MessageParticipantType;
|
||||
senderId?: string | null;
|
||||
recipientType: MessageParticipantType;
|
||||
recipientId?: string | null;
|
||||
type?: MessageType;
|
||||
content: string;
|
||||
requiresResponse?: boolean;
|
||||
status?: MessageStatus;
|
||||
parentMessageId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data for updating a message.
|
||||
* Partial of create data - all fields optional.
|
||||
*/
|
||||
export type UpdateMessageData = Partial<CreateMessageData>;
|
||||
|
||||
/**
|
||||
* Message Repository Port
|
||||
*
|
||||
* Defines operations for the Message aggregate.
|
||||
* Enables message persistence for agent-user communication.
|
||||
*/
|
||||
export interface MessageRepository {
|
||||
/**
|
||||
* Create a new message.
|
||||
* Generates id and sets timestamps automatically.
|
||||
*/
|
||||
create(data: CreateMessageData): Promise<Message>;
|
||||
|
||||
/**
|
||||
* Find a message by its ID.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
findById(id: string): Promise<Message | null>;
|
||||
|
||||
/**
|
||||
* Find messages by sender.
|
||||
* @param type - 'agent' or 'user'
|
||||
* @param id - Optional sender ID (agent ID if type='agent', omit for user)
|
||||
* Returns messages ordered by createdAt DESC.
|
||||
*/
|
||||
findBySender(type: MessageParticipantType, id?: string): Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Find messages by recipient.
|
||||
* @param type - 'agent' or 'user'
|
||||
* @param id - Optional recipient ID (agent ID if type='agent', omit for user)
|
||||
* Returns messages ordered by createdAt DESC.
|
||||
*/
|
||||
findByRecipient(type: MessageParticipantType, id?: string): Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Find all pending messages for user.
|
||||
* Returns messages where recipientType='user' and status='pending'.
|
||||
* Ordered by createdAt DESC.
|
||||
*/
|
||||
findPendingForUser(): Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Find all messages requiring a response.
|
||||
* Returns messages where requiresResponse=true and status='pending'.
|
||||
* Ordered by createdAt DESC.
|
||||
*/
|
||||
findRequiringResponse(): Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Find all replies to a message.
|
||||
* @param parentMessageId - The ID of the parent message
|
||||
* Returns messages ordered by createdAt DESC.
|
||||
*/
|
||||
findReplies(parentMessageId: string): Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Update a message.
|
||||
* Throws if message not found.
|
||||
* Updates updatedAt timestamp automatically.
|
||||
*/
|
||||
update(id: string, data: UpdateMessageData): Promise<Message>;
|
||||
|
||||
/**
|
||||
* Delete a message.
|
||||
* Throws if message not found.
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
34
apps/server/db/repositories/page-repository.ts
Normal file
34
apps/server/db/repositories/page-repository.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Page Repository Port Interface
|
||||
*
|
||||
* Port for Page aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { Page, NewPage } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Data for creating a new page.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreatePageData = Omit<NewPage, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* Data for updating a page.
|
||||
*/
|
||||
export type UpdatePageData = Partial<Pick<NewPage, 'title' | 'content' | 'sortOrder'>>;
|
||||
|
||||
/**
|
||||
* Page Repository Port
|
||||
*/
|
||||
export interface PageRepository {
|
||||
create(data: CreatePageData): Promise<Page>;
|
||||
findById(id: string): Promise<Page | null>;
|
||||
findByIds(ids: string[]): Promise<Page[]>;
|
||||
findByInitiativeId(initiativeId: string): Promise<Page[]>;
|
||||
findByParentPageId(parentPageId: string): Promise<Page[]>;
|
||||
findRootPage(initiativeId: string): Promise<Page | null>;
|
||||
getOrCreateRootPage(initiativeId: string): Promise<Page>;
|
||||
update(id: string, data: UpdatePageData): Promise<Page>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
92
apps/server/db/repositories/phase-repository.ts
Normal file
92
apps/server/db/repositories/phase-repository.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Phase Repository Port Interface
|
||||
*
|
||||
* Port for Phase aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { Phase, NewPhase } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Data for creating a new phase.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreatePhaseData = Omit<NewPhase, 'id' | 'createdAt' | 'updatedAt'> & { id?: string };
|
||||
|
||||
/**
|
||||
* Data for updating a phase.
|
||||
* Partial of creation data - all fields optional.
|
||||
*/
|
||||
export type UpdatePhaseData = Partial<CreatePhaseData>;
|
||||
|
||||
/**
|
||||
* Phase Repository Port
|
||||
*
|
||||
* Defines operations for the Phase aggregate.
|
||||
* Only knows about phases - no knowledge of parent or child entities.
|
||||
*/
|
||||
export interface PhaseRepository {
|
||||
/**
|
||||
* Create a new phase.
|
||||
* Generates id and sets timestamps automatically.
|
||||
* Foreign key to initiative enforced by database.
|
||||
*/
|
||||
create(data: CreatePhaseData): Promise<Phase>;
|
||||
|
||||
/**
|
||||
* Find a phase by its ID.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
findById(id: string): Promise<Phase | null>;
|
||||
|
||||
/**
|
||||
* Find all phases for an initiative.
|
||||
* Returns phases ordered by createdAt.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findByInitiativeId(initiativeId: string): Promise<Phase[]>;
|
||||
|
||||
/**
|
||||
* Find all dependency edges for phases in an initiative.
|
||||
* Returns array of { phaseId, dependsOnPhaseId } pairs.
|
||||
*/
|
||||
findDependenciesByInitiativeId(initiativeId: string): Promise<Array<{ phaseId: string; dependsOnPhaseId: string }>>;
|
||||
|
||||
/**
|
||||
* Update a phase.
|
||||
* Throws if phase not found.
|
||||
* Updates updatedAt timestamp automatically.
|
||||
*/
|
||||
update(id: string, data: UpdatePhaseData): Promise<Phase>;
|
||||
|
||||
/**
|
||||
* Delete a phase.
|
||||
* Throws if phase not found.
|
||||
* Cascades to child plans and tasks via FK constraints.
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Create a dependency between two phases.
|
||||
* The phase identified by phaseId will depend on dependsOnPhaseId.
|
||||
* Both phases must exist.
|
||||
*/
|
||||
createDependency(phaseId: string, dependsOnPhaseId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get IDs of phases that this phase depends on.
|
||||
* Returns empty array if no dependencies.
|
||||
*/
|
||||
getDependencies(phaseId: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Get IDs of phases that depend on this phase.
|
||||
* Returns empty array if no dependents.
|
||||
*/
|
||||
getDependents(phaseId: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Remove a dependency between two phases.
|
||||
*/
|
||||
removeDependency(phaseId: string, dependsOnPhaseId: string): Promise<void>;
|
||||
}
|
||||
38
apps/server/db/repositories/project-repository.ts
Normal file
38
apps/server/db/repositories/project-repository.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Project Repository Port Interface
|
||||
*
|
||||
* Port for Project aggregate operations and initiative-project junction.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { Project, NewProject } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Data for creating a new project.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
*/
|
||||
export type CreateProjectData = Omit<NewProject, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* Data for updating a project.
|
||||
* Name is immutable (used as directory name for worktrees).
|
||||
*/
|
||||
export type UpdateProjectData = Omit<Partial<CreateProjectData>, 'name'>;
|
||||
|
||||
/**
|
||||
* Project Repository Port
|
||||
*/
|
||||
export interface ProjectRepository {
|
||||
create(data: CreateProjectData): Promise<Project>;
|
||||
findById(id: string): Promise<Project | null>;
|
||||
findByName(name: string): Promise<Project | null>;
|
||||
findAll(): Promise<Project[]>;
|
||||
update(id: string, data: UpdateProjectData): Promise<Project>;
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
// Junction ops
|
||||
addProjectToInitiative(initiativeId: string, projectId: string): Promise<void>;
|
||||
removeProjectFromInitiative(initiativeId: string, projectId: string): Promise<void>;
|
||||
findProjectsByInitiativeId(initiativeId: string): Promise<Project[]>;
|
||||
setInitiativeProjects(initiativeId: string, projectIds: string[]): Promise<void>;
|
||||
}
|
||||
105
apps/server/db/repositories/task-repository.ts
Normal file
105
apps/server/db/repositories/task-repository.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Task Repository Port Interface
|
||||
*
|
||||
* Port for Task aggregate operations.
|
||||
* Implementations (Drizzle, etc.) are adapters.
|
||||
*/
|
||||
|
||||
import type { Task, NewTask, TaskCategory } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Data for creating a new task.
|
||||
* Omits system-managed fields (id, createdAt, updatedAt).
|
||||
* At least one of phaseId, initiativeId, or parentTaskId should be provided.
|
||||
*/
|
||||
export type CreateTaskData = Omit<NewTask, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
/**
|
||||
* Data for updating a task.
|
||||
* Partial of creation data - all fields optional.
|
||||
*/
|
||||
export type UpdateTaskData = Partial<CreateTaskData>;
|
||||
|
||||
/**
|
||||
* Filters for finding pending approval tasks.
|
||||
*/
|
||||
export interface PendingApprovalFilters {
|
||||
initiativeId?: string;
|
||||
phaseId?: string;
|
||||
category?: TaskCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Repository Port
|
||||
*
|
||||
* Defines operations for the Task aggregate.
|
||||
* Only knows about tasks - no knowledge of parent entities.
|
||||
*/
|
||||
export interface TaskRepository {
|
||||
/**
|
||||
* Create a new task.
|
||||
* Generates id and sets timestamps automatically.
|
||||
* At least one parent context (phaseId, initiativeId, or parentTaskId) should be set.
|
||||
*/
|
||||
create(data: CreateTaskData): Promise<Task>;
|
||||
|
||||
/**
|
||||
* Find a task by its ID.
|
||||
* Returns null if not found.
|
||||
*/
|
||||
findById(id: string): Promise<Task | null>;
|
||||
|
||||
/**
|
||||
* Find all child tasks of a parent task.
|
||||
* Returns tasks ordered by order field.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findByParentTaskId(parentTaskId: string): Promise<Task[]>;
|
||||
|
||||
/**
|
||||
* Find all tasks directly linked to an initiative.
|
||||
* Returns tasks ordered by order field.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findByInitiativeId(initiativeId: string): Promise<Task[]>;
|
||||
|
||||
/**
|
||||
* Find all tasks directly linked to a phase.
|
||||
* Returns tasks ordered by order field.
|
||||
* Returns empty array if none exist.
|
||||
*/
|
||||
findByPhaseId(phaseId: string): Promise<Task[]>;
|
||||
|
||||
/**
|
||||
* Find all tasks with status 'pending_approval'.
|
||||
* Optional filters by initiative, phase, or category.
|
||||
* Returns tasks ordered by createdAt.
|
||||
*/
|
||||
findPendingApproval(filters?: PendingApprovalFilters): Promise<Task[]>;
|
||||
|
||||
/**
|
||||
* Update a task.
|
||||
* Throws if task not found.
|
||||
* Updates updatedAt timestamp automatically.
|
||||
*/
|
||||
update(id: string, data: UpdateTaskData): Promise<Task>;
|
||||
|
||||
/**
|
||||
* Delete a task.
|
||||
* Throws if task not found.
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Create a dependency between two tasks.
|
||||
* The task identified by taskId will depend on dependsOnTaskId.
|
||||
* Both tasks must exist.
|
||||
*/
|
||||
createDependency(taskId: string, dependsOnTaskId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get all task IDs that a task depends on.
|
||||
* Returns empty array if no dependencies.
|
||||
*/
|
||||
getDependencies(taskId: string): Promise<string[]>;
|
||||
}
|
||||
537
apps/server/db/schema.ts
Normal file
537
apps/server/db/schema.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* Database schema for Codewalk District.
|
||||
*
|
||||
* Defines the three-level task hierarchy:
|
||||
* - Initiative: Top-level project
|
||||
* - Phase: Major milestone within initiative
|
||||
* - Task: Individual work item (can have parentTaskId for decomposition relationships)
|
||||
*
|
||||
* Plus a task_dependencies table for task dependency relationships.
|
||||
*/
|
||||
|
||||
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core';
|
||||
import { relations, type InferInsertModel, type InferSelectModel } from 'drizzle-orm';
|
||||
|
||||
// ============================================================================
|
||||
// INITIATIVES
|
||||
// ============================================================================
|
||||
|
||||
export const initiatives = sqliteTable('initiatives', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
status: text('status', { enum: ['active', 'completed', 'archived'] })
|
||||
.notNull()
|
||||
.default('active'),
|
||||
mergeRequiresApproval: integer('merge_requires_approval', { mode: 'boolean' })
|
||||
.notNull()
|
||||
.default(true),
|
||||
branch: text('branch'), // Auto-generated initiative branch (e.g., 'cw/user-auth')
|
||||
executionMode: text('execution_mode', { enum: ['yolo', 'review_per_phase'] })
|
||||
.notNull()
|
||||
.default('review_per_phase'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const initiativesRelations = relations(initiatives, ({ many }) => ({
|
||||
phases: many(phases),
|
||||
pages: many(pages),
|
||||
initiativeProjects: many(initiativeProjects),
|
||||
tasks: many(tasks),
|
||||
changeSets: many(changeSets),
|
||||
}));
|
||||
|
||||
export type Initiative = InferSelectModel<typeof initiatives>;
|
||||
export type NewInitiative = InferInsertModel<typeof initiatives>;
|
||||
|
||||
// ============================================================================
|
||||
// PHASES
|
||||
// ============================================================================
|
||||
|
||||
export const phases = sqliteTable('phases', {
|
||||
id: text('id').primaryKey(),
|
||||
initiativeId: text('initiative_id')
|
||||
.notNull()
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
content: text('content'),
|
||||
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] })
|
||||
.notNull()
|
||||
.default('pending'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const phasesRelations = relations(phases, ({ one, many }) => ({
|
||||
initiative: one(initiatives, {
|
||||
fields: [phases.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
tasks: many(tasks),
|
||||
// Dependencies: phases this phase depends on
|
||||
dependsOn: many(phaseDependencies, { relationName: 'dependentPhase' }),
|
||||
// Dependents: phases that depend on this phase
|
||||
dependents: many(phaseDependencies, { relationName: 'dependencyPhase' }),
|
||||
}));
|
||||
|
||||
export type Phase = InferSelectModel<typeof phases>;
|
||||
export type NewPhase = InferInsertModel<typeof phases>;
|
||||
|
||||
// ============================================================================
|
||||
// PHASE DEPENDENCIES
|
||||
// ============================================================================
|
||||
|
||||
export const phaseDependencies = sqliteTable('phase_dependencies', {
|
||||
id: text('id').primaryKey(),
|
||||
phaseId: text('phase_id')
|
||||
.notNull()
|
||||
.references(() => phases.id, { onDelete: 'cascade' }),
|
||||
dependsOnPhaseId: text('depends_on_phase_id')
|
||||
.notNull()
|
||||
.references(() => phases.id, { onDelete: 'cascade' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const phaseDependenciesRelations = relations(phaseDependencies, ({ one }) => ({
|
||||
phase: one(phases, {
|
||||
fields: [phaseDependencies.phaseId],
|
||||
references: [phases.id],
|
||||
relationName: 'dependentPhase',
|
||||
}),
|
||||
dependsOnPhase: one(phases, {
|
||||
fields: [phaseDependencies.dependsOnPhaseId],
|
||||
references: [phases.id],
|
||||
relationName: 'dependencyPhase',
|
||||
}),
|
||||
}));
|
||||
|
||||
export type PhaseDependency = InferSelectModel<typeof phaseDependencies>;
|
||||
export type NewPhaseDependency = InferInsertModel<typeof phaseDependencies>;
|
||||
|
||||
// ============================================================================
|
||||
// TASKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Task category enum values.
|
||||
* Defines what kind of work a task represents.
|
||||
*/
|
||||
export const TASK_CATEGORIES = [
|
||||
'execute', // Standard execution task
|
||||
'research', // Research/exploration task
|
||||
'discuss', // Discussion/context gathering
|
||||
'plan', // Plan initiative into phases
|
||||
'detail', // Detail phase into tasks
|
||||
'refine', // Refine/edit content
|
||||
'verify', // Verification task
|
||||
'merge', // Merge task
|
||||
'review', // Review/approval task
|
||||
] as const;
|
||||
|
||||
export type TaskCategory = (typeof TASK_CATEGORIES)[number];
|
||||
|
||||
export const tasks = sqliteTable('tasks', {
|
||||
id: text('id').primaryKey(),
|
||||
// Parent context - at least one should be set
|
||||
phaseId: text('phase_id').references(() => phases.id, { onDelete: 'cascade' }),
|
||||
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
// Parent task for detail hierarchy (child tasks link to parent detail task)
|
||||
parentTaskId: text('parent_task_id').references((): ReturnType<typeof text> => tasks.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
type: text('type', {
|
||||
enum: ['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action'],
|
||||
})
|
||||
.notNull()
|
||||
.default('auto'),
|
||||
category: text('category', {
|
||||
enum: TASK_CATEGORIES,
|
||||
})
|
||||
.notNull()
|
||||
.default('execute'),
|
||||
priority: text('priority', { enum: ['low', 'medium', 'high'] })
|
||||
.notNull()
|
||||
.default('medium'),
|
||||
status: text('status', {
|
||||
enum: ['pending_approval', 'pending', 'in_progress', 'completed', 'blocked'],
|
||||
})
|
||||
.notNull()
|
||||
.default('pending'),
|
||||
requiresApproval: integer('requires_approval', { mode: 'boolean' }), // null = inherit from initiative
|
||||
order: integer('order').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const tasksRelations = relations(tasks, ({ one, many }) => ({
|
||||
phase: one(phases, {
|
||||
fields: [tasks.phaseId],
|
||||
references: [phases.id],
|
||||
}),
|
||||
initiative: one(initiatives, {
|
||||
fields: [tasks.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
// Parent task (for detail hierarchy - child links to parent detail task)
|
||||
parentTask: one(tasks, {
|
||||
fields: [tasks.parentTaskId],
|
||||
references: [tasks.id],
|
||||
relationName: 'parentTask',
|
||||
}),
|
||||
// Child tasks (tasks created from decomposition of this task)
|
||||
childTasks: many(tasks, { relationName: 'parentTask' }),
|
||||
// Dependencies: tasks this task depends on
|
||||
dependsOn: many(taskDependencies, { relationName: 'dependentTask' }),
|
||||
// Dependents: tasks that depend on this task
|
||||
dependents: many(taskDependencies, { relationName: 'dependencyTask' }),
|
||||
}));
|
||||
|
||||
export type Task = InferSelectModel<typeof tasks>;
|
||||
export type NewTask = InferInsertModel<typeof tasks>;
|
||||
|
||||
// ============================================================================
|
||||
// TASK DEPENDENCIES
|
||||
// ============================================================================
|
||||
|
||||
export const taskDependencies = sqliteTable('task_dependencies', {
|
||||
id: text('id').primaryKey(),
|
||||
taskId: text('task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
dependsOnTaskId: text('depends_on_task_id')
|
||||
.notNull()
|
||||
.references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const taskDependenciesRelations = relations(taskDependencies, ({ one }) => ({
|
||||
task: one(tasks, {
|
||||
fields: [taskDependencies.taskId],
|
||||
references: [tasks.id],
|
||||
relationName: 'dependentTask',
|
||||
}),
|
||||
dependsOnTask: one(tasks, {
|
||||
fields: [taskDependencies.dependsOnTaskId],
|
||||
references: [tasks.id],
|
||||
relationName: 'dependencyTask',
|
||||
}),
|
||||
}));
|
||||
|
||||
export type TaskDependency = InferSelectModel<typeof taskDependencies>;
|
||||
export type NewTaskDependency = InferInsertModel<typeof taskDependencies>;
|
||||
|
||||
// ============================================================================
|
||||
// ACCOUNTS
|
||||
// ============================================================================
|
||||
|
||||
export const accounts = sqliteTable('accounts', {
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
provider: text('provider').notNull().default('claude'),
|
||||
configJson: text('config_json'), // .claude.json content (JSON string)
|
||||
credentials: text('credentials'), // .credentials.json content (JSON string)
|
||||
isExhausted: integer('is_exhausted', { mode: 'boolean' }).notNull().default(false),
|
||||
exhaustedUntil: integer('exhausted_until', { mode: 'timestamp' }),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp' }),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const accountsRelations = relations(accounts, ({ many }) => ({
|
||||
agents: many(agents),
|
||||
}));
|
||||
|
||||
export type Account = InferSelectModel<typeof accounts>;
|
||||
export type NewAccount = InferInsertModel<typeof accounts>;
|
||||
|
||||
// ============================================================================
|
||||
// AGENTS
|
||||
// ============================================================================
|
||||
|
||||
export const agents = sqliteTable('agents', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull().unique(), // Human-readable alias (e.g., 'jolly-penguin')
|
||||
taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }), // Task may be deleted
|
||||
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'set null' }),
|
||||
sessionId: text('session_id'), // Claude CLI session ID for resumption (null until first run completes)
|
||||
worktreeId: text('worktree_id').notNull(), // Agent alias (deterministic path: agent-workdirs/<alias>/)
|
||||
provider: text('provider').notNull().default('claude'),
|
||||
accountId: text('account_id').references(() => accounts.id, { onDelete: 'set null' }),
|
||||
status: text('status', {
|
||||
enum: ['idle', 'running', 'waiting_for_input', 'stopped', 'crashed'],
|
||||
})
|
||||
.notNull()
|
||||
.default('idle'),
|
||||
mode: text('mode', { enum: ['execute', 'discuss', 'plan', 'detail', 'refine'] })
|
||||
.notNull()
|
||||
.default('execute'),
|
||||
pid: integer('pid'),
|
||||
exitCode: integer('exit_code'), // Process exit code for debugging crashes
|
||||
outputFilePath: text('output_file_path'),
|
||||
result: text('result'),
|
||||
pendingQuestions: text('pending_questions'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
userDismissedAt: integer('user_dismissed_at', { mode: 'timestamp' }),
|
||||
});
|
||||
|
||||
export const agentsRelations = relations(agents, ({ one, many }) => ({
|
||||
task: one(tasks, {
|
||||
fields: [agents.taskId],
|
||||
references: [tasks.id],
|
||||
}),
|
||||
initiative: one(initiatives, {
|
||||
fields: [agents.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
account: one(accounts, {
|
||||
fields: [agents.accountId],
|
||||
references: [accounts.id],
|
||||
}),
|
||||
changeSets: many(changeSets),
|
||||
}));
|
||||
|
||||
export type Agent = InferSelectModel<typeof agents>;
|
||||
export type NewAgent = InferInsertModel<typeof agents>;
|
||||
|
||||
// ============================================================================
|
||||
// CHANGE SETS
|
||||
// ============================================================================
|
||||
|
||||
export const changeSets = sqliteTable('change_sets', {
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id')
|
||||
.references(() => agents.id, { onDelete: 'set null' }),
|
||||
agentName: text('agent_name').notNull(),
|
||||
initiativeId: text('initiative_id')
|
||||
.notNull()
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
mode: text('mode', { enum: ['plan', 'detail', 'refine'] }).notNull(),
|
||||
summary: text('summary'),
|
||||
status: text('status', { enum: ['applied', 'reverted'] })
|
||||
.notNull()
|
||||
.default('applied'),
|
||||
revertedAt: integer('reverted_at', { mode: 'timestamp' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
index('change_sets_initiative_id_idx').on(table.initiativeId),
|
||||
]);
|
||||
|
||||
export const changeSetsRelations = relations(changeSets, ({ one, many }) => ({
|
||||
agent: one(agents, {
|
||||
fields: [changeSets.agentId],
|
||||
references: [agents.id],
|
||||
}),
|
||||
initiative: one(initiatives, {
|
||||
fields: [changeSets.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
entries: many(changeSetEntries),
|
||||
}));
|
||||
|
||||
export type ChangeSet = InferSelectModel<typeof changeSets>;
|
||||
export type NewChangeSet = InferInsertModel<typeof changeSets>;
|
||||
|
||||
export const changeSetEntries = sqliteTable('change_set_entries', {
|
||||
id: text('id').primaryKey(),
|
||||
changeSetId: text('change_set_id')
|
||||
.notNull()
|
||||
.references(() => changeSets.id, { onDelete: 'cascade' }),
|
||||
entityType: text('entity_type', { enum: ['page', 'phase', 'task', 'phase_dependency'] }).notNull(),
|
||||
entityId: text('entity_id').notNull(),
|
||||
action: text('action', { enum: ['create', 'update', 'delete'] }).notNull(),
|
||||
previousState: text('previous_state'), // JSON snapshot, null for creates
|
||||
newState: text('new_state'), // JSON snapshot, null for deletes
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
index('change_set_entries_change_set_id_idx').on(table.changeSetId),
|
||||
]);
|
||||
|
||||
export const changeSetEntriesRelations = relations(changeSetEntries, ({ one }) => ({
|
||||
changeSet: one(changeSets, {
|
||||
fields: [changeSetEntries.changeSetId],
|
||||
references: [changeSets.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type ChangeSetEntry = InferSelectModel<typeof changeSetEntries>;
|
||||
export type NewChangeSetEntry = InferInsertModel<typeof changeSetEntries>;
|
||||
|
||||
// ============================================================================
|
||||
// MESSAGES
|
||||
// ============================================================================
|
||||
|
||||
export const messages = sqliteTable('messages', {
|
||||
id: text('id').primaryKey(),
|
||||
senderType: text('sender_type', { enum: ['agent', 'user'] }).notNull(),
|
||||
senderId: text('sender_id').references(() => agents.id, { onDelete: 'set null' }), // Agent ID if senderType='agent', null for user
|
||||
recipientType: text('recipient_type', { enum: ['agent', 'user'] }).notNull(),
|
||||
recipientId: text('recipient_id').references(() => agents.id, { onDelete: 'set null' }), // Agent ID if recipientType='agent', null for user
|
||||
type: text('type', { enum: ['question', 'info', 'error', 'response'] })
|
||||
.notNull()
|
||||
.default('info'),
|
||||
content: text('content').notNull(),
|
||||
requiresResponse: integer('requires_response', { mode: 'boolean' })
|
||||
.notNull()
|
||||
.default(false),
|
||||
status: text('status', { enum: ['pending', 'read', 'responded'] })
|
||||
.notNull()
|
||||
.default('pending'),
|
||||
parentMessageId: text('parent_message_id').references((): ReturnType<typeof text> => messages.id, { onDelete: 'set null' }), // Links response to original question
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one, many }) => ({
|
||||
// Sender agent (optional - null for user senders)
|
||||
senderAgent: one(agents, {
|
||||
fields: [messages.senderId],
|
||||
references: [agents.id],
|
||||
relationName: 'senderAgent',
|
||||
}),
|
||||
// Recipient agent (optional - null for user recipients)
|
||||
recipientAgent: one(agents, {
|
||||
fields: [messages.recipientId],
|
||||
references: [agents.id],
|
||||
relationName: 'recipientAgent',
|
||||
}),
|
||||
// Parent message (for threading responses)
|
||||
parentMessage: one(messages, {
|
||||
fields: [messages.parentMessageId],
|
||||
references: [messages.id],
|
||||
relationName: 'parentMessage',
|
||||
}),
|
||||
// Child messages (replies to this message)
|
||||
childMessages: many(messages, { relationName: 'parentMessage' }),
|
||||
}));
|
||||
|
||||
export type Message = InferSelectModel<typeof messages>;
|
||||
export type NewMessage = InferInsertModel<typeof messages>;
|
||||
|
||||
// ============================================================================
|
||||
// PAGES
|
||||
// ============================================================================
|
||||
|
||||
export const pages = sqliteTable('pages', {
|
||||
id: text('id').primaryKey(),
|
||||
initiativeId: text('initiative_id')
|
||||
.notNull()
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
parentPageId: text('parent_page_id').references((): ReturnType<typeof text> => pages.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
content: text('content'), // JSON string from Tiptap
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const pagesRelations = relations(pages, ({ one, many }) => ({
|
||||
initiative: one(initiatives, {
|
||||
fields: [pages.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
parentPage: one(pages, {
|
||||
fields: [pages.parentPageId],
|
||||
references: [pages.id],
|
||||
relationName: 'parentPage',
|
||||
}),
|
||||
childPages: many(pages, { relationName: 'parentPage' }),
|
||||
}));
|
||||
|
||||
export type Page = InferSelectModel<typeof pages>;
|
||||
export type NewPage = InferInsertModel<typeof pages>;
|
||||
|
||||
// ============================================================================
|
||||
// PROJECTS
|
||||
// ============================================================================
|
||||
|
||||
export const projects = sqliteTable('projects', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull().unique(),
|
||||
url: text('url').notNull().unique(),
|
||||
defaultBranch: text('default_branch').notNull().default('main'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const projectsRelations = relations(projects, ({ many }) => ({
|
||||
initiativeProjects: many(initiativeProjects),
|
||||
}));
|
||||
|
||||
export type Project = InferSelectModel<typeof projects>;
|
||||
export type NewProject = InferInsertModel<typeof projects>;
|
||||
|
||||
// ============================================================================
|
||||
// INITIATIVE PROJECTS (junction)
|
||||
// ============================================================================
|
||||
|
||||
export const initiativeProjects = sqliteTable('initiative_projects', {
|
||||
id: text('id').primaryKey(),
|
||||
initiativeId: text('initiative_id')
|
||||
.notNull()
|
||||
.references(() => initiatives.id, { onDelete: 'cascade' }),
|
||||
projectId: text('project_id')
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
uniqueIndex('initiative_project_unique').on(table.initiativeId, table.projectId),
|
||||
]);
|
||||
|
||||
export const initiativeProjectsRelations = relations(initiativeProjects, ({ one }) => ({
|
||||
initiative: one(initiatives, {
|
||||
fields: [initiativeProjects.initiativeId],
|
||||
references: [initiatives.id],
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [initiativeProjects.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export type InitiativeProject = InferSelectModel<typeof initiativeProjects>;
|
||||
export type NewInitiativeProject = InferInsertModel<typeof initiativeProjects>;
|
||||
|
||||
// ============================================================================
|
||||
// AGENT LOG CHUNKS
|
||||
// ============================================================================
|
||||
|
||||
export const agentLogChunks = sqliteTable('agent_log_chunks', {
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id').notNull(), // NO FK — survives agent deletion
|
||||
agentName: text('agent_name').notNull(), // Snapshot for display after deletion
|
||||
sessionNumber: integer('session_number').notNull().default(1),
|
||||
content: text('content').notNull(), // Raw JSONL chunk from file
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
index('agent_log_chunks_agent_id_idx').on(table.agentId),
|
||||
]);
|
||||
|
||||
export type AgentLogChunk = InferSelectModel<typeof agentLogChunks>;
|
||||
export type NewAgentLogChunk = InferInsertModel<typeof agentLogChunks>;
|
||||
|
||||
// ============================================================================
|
||||
// CONVERSATIONS (inter-agent communication)
|
||||
// ============================================================================
|
||||
|
||||
export const conversations = sqliteTable('conversations', {
|
||||
id: text('id').primaryKey(),
|
||||
fromAgentId: text('from_agent_id').notNull().references(() => agents.id, { onDelete: 'cascade' }),
|
||||
toAgentId: text('to_agent_id').notNull().references(() => agents.id, { onDelete: 'cascade' }),
|
||||
initiativeId: text('initiative_id').references(() => initiatives.id, { onDelete: 'set null' }),
|
||||
phaseId: text('phase_id').references(() => phases.id, { onDelete: 'set null' }),
|
||||
taskId: text('task_id').references(() => tasks.id, { onDelete: 'set null' }),
|
||||
question: text('question').notNull(),
|
||||
answer: text('answer'),
|
||||
status: text('status', { enum: ['pending', 'answered'] }).notNull().default('pending'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => [
|
||||
index('conversations_to_agent_status_idx').on(table.toAgentId, table.status),
|
||||
index('conversations_from_agent_idx').on(table.fromAgentId),
|
||||
]);
|
||||
|
||||
export type Conversation = InferSelectModel<typeof conversations>;
|
||||
export type NewConversation = InferInsertModel<typeof conversations>;
|
||||
Reference in New Issue
Block a user