Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

*No unreleased changes at this time.*
### 🚀 Performance Improvements
- **Pagination for findAll() methods**: Added `limit` and `offset` parameters to `TaskRepository.findAll()` and `DependencyRepository.findAll()` with default limit of 100 records per page
- **New findAllUnbounded() methods**: Explicit unbounded retrieval for operations that genuinely need all records (e.g., graph initialization)
- **New count() methods**: Support pagination UI with total record counts without fetching all data

### ⚠️ Breaking Changes
- **findAll() now returns max 100 results by default**: Existing code calling `findAll()` without parameters will receive paginated results. Use `findAllUnbounded()` if you need all records.

### 🏗️ Architecture
- **Explicit unbounded queries**: `DependencyHandler.create()` now uses `findAllUnbounded()` with architecture comment explaining why graph initialization requires all dependencies

---

Expand Down
3 changes: 2 additions & 1 deletion docs/TASK-DEPENDENCIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,8 @@ If you're getting cycle detection errors but believe your graph is acyclic:

1. **Visualize the dependency graph:**
```typescript
const allDeps = await dependencyRepo.findAll();
// Use findAllUnbounded() to see ALL dependencies (findAll() returns max 100)
const allDeps = await dependencyRepo.findAllUnbounded();
console.log('All dependencies:', allDeps.value);
```

Expand Down
56 changes: 53 additions & 3 deletions src/core/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,36 @@ export interface TaskRepository {
save(task: Task): Promise<Result<void>>;
update(taskId: TaskId, update: Partial<Task>): Promise<Result<void>>;
findById(taskId: TaskId): Promise<Result<Task | null>>;
findAll(): Promise<Result<readonly Task[]>>;
/**
* Find tasks with optional pagination
*
* All implementations MUST use DEFAULT_LIMIT = 100 when limit is not specified.
* This ensures consistent behavior across implementations.
*
* @param limit Maximum results to return (default: 100, max recommended: 1000)
* @param offset Skip first N results (default: 0)
* @returns Paginated task list ordered by created_at DESC
*/
findAll(limit?: number, offset?: number): Promise<Result<readonly Task[]>>;
/**
* Find all tasks without pagination limit
* ARCHITECTURE: Use only when you genuinely need ALL tasks (e.g., graph initialization)
* For user-facing queries, use findAll() with pagination instead
* @returns All tasks ordered by created_at DESC
*/
findAllUnbounded(): Promise<Result<readonly Task[]>>;
/**
* Count total tasks in repository
* @returns Total task count (useful for pagination UI)
*/
count(): Promise<Result<number>>;
/**
* Find tasks by status (returns all matching tasks - NOT paginated)
* NOTE: Unlike findAll(), this method has no pagination limit.
* For large datasets, consider using findAll() with application-level filtering.
* @param status Task status to filter by
* @returns All tasks matching the status
*/
findByStatus(status: string): Promise<Result<readonly Task[]>>;
delete(taskId: TaskId): Promise<Result<void>>;
cleanupOldTasks(olderThanMs: number): Promise<Result<number>>;
Expand Down Expand Up @@ -156,9 +185,30 @@ export interface DependencyRepository {
isBlocked(taskId: TaskId): Promise<Result<boolean>>;

/**
* Get all dependencies in the system
* Get dependencies with optional pagination
*
* All implementations MUST use DEFAULT_LIMIT = 100 when limit is not specified.
* This ensures consistent behavior across implementations.
*
* @param limit Maximum results to return (default: 100, max recommended: 1000)
* @param offset Skip first N results (default: 0)
* @returns Paginated dependencies ordered by created_at DESC
*/
findAll(limit?: number, offset?: number): Promise<Result<readonly TaskDependency[]>>;

/**
* Get all dependencies without pagination limit
* ARCHITECTURE: Use only for graph initialization (DependencyHandler.create())
* For user queries, use findAll() with pagination instead
* @returns All dependencies ordered by created_at DESC
*/
findAllUnbounded(): Promise<Result<readonly TaskDependency[]>>;

/**
* Count total dependencies in repository
* @returns Total dependency count
*/
findAll(): Promise<Result<readonly TaskDependency[]>>;
count(): Promise<Result<number>>;

/**
* Remove all dependencies for a task (on task deletion)
Expand Down
99 changes: 87 additions & 12 deletions src/implementations/dependency-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ export class SQLiteDependencyRepository implements DependencyRepository {
private static readonly MAX_DEPENDENCIES_PER_TASK = 100;
// NOTE: MAX_DEPENDENCY_CHAIN_DEPTH moved to DependencyHandler (see line 24)

/** Default pagination limit for findAll() */
private static readonly DEFAULT_LIMIT = 100;

private readonly db: SQLite.Database;
private readonly addDependencyStmt: SQLite.Statement;
private readonly getDependenciesStmt: SQLite.Statement;
Expand All @@ -52,11 +55,13 @@ export class SQLiteDependencyRepository implements DependencyRepository {
private readonly resolveDependenciesBatchStmt: SQLite.Statement;
private readonly getUnresolvedDependenciesStmt: SQLite.Statement;
private readonly isBlockedStmt: SQLite.Statement;
private readonly findAllStmt: SQLite.Statement;
private readonly findAllUnboundedStmt: SQLite.Statement;
private readonly deleteDependenciesStmt: SQLite.Statement;
private readonly checkDependencyExistsStmt: SQLite.Statement;
private readonly getDependencyByIdStmt: SQLite.Statement;
private readonly checkTaskExistsStmt: SQLite.Statement;
private readonly countStmt: SQLite.Statement;
private readonly findAllPaginatedStmt: SQLite.Statement;

constructor(database: Database) {
this.db = database.getDatabase();
Expand Down Expand Up @@ -98,10 +103,18 @@ export class SQLiteDependencyRepository implements DependencyRepository {
WHERE task_id = ? AND resolution = 'pending'
`);

this.findAllStmt = this.db.prepare(`
this.findAllUnboundedStmt = this.db.prepare(`
SELECT * FROM task_dependencies ORDER BY created_at DESC
`);

this.countStmt = this.db.prepare(`
SELECT COUNT(*) as count FROM task_dependencies
`);

this.findAllPaginatedStmt = this.db.prepare(`
SELECT * FROM task_dependencies ORDER BY created_at DESC LIMIT ? OFFSET ?
`);

this.deleteDependenciesStmt = this.db.prepare(`
DELETE FROM task_dependencies
WHERE task_id = ? OR depends_on_task_id = ?
Expand Down Expand Up @@ -478,32 +491,94 @@ export class SQLiteDependencyRepository implements DependencyRepository {
}

/**
* Get all dependencies in the system
* Get dependencies with pagination
*
* Returns TaskDependency records ordered by created_at DESC with pagination.
* Default limit is 100 records per page.
*
* For complete dependency graph construction, use findAllUnbounded() instead.
*
* @param limit Maximum results to return (default: 100)
* @param offset Number of records to skip (default: 0)
* @returns Result containing paginated array of TaskDependency objects or error
*
* @example
* ```typescript
* // Get first page (100 records)
* const page1 = await dependencyRepo.findAll();
*
* // Get second page
* const page2 = await dependencyRepo.findAll(100, 100);
*
* // Get all dependencies (no pagination)
* const all = await dependencyRepo.findAllUnbounded();
* ```
*/
async findAll(limit?: number, offset?: number): Promise<Result<readonly TaskDependency[]>> {
return tryCatchAsync(
async () => {
const effectiveLimit = limit ?? SQLiteDependencyRepository.DEFAULT_LIMIT;
const effectiveOffset = offset ?? 0;

const rows = this.findAllPaginatedStmt.all(effectiveLimit, effectiveOffset) as DependencyRow[];
return rows.map(row => this.rowToDependency(row));
},
operationErrorHandler('find all dependencies')
);
}

/**
* Get all dependencies without pagination limit
*
* Returns all TaskDependency records ordered by created_at DESC.
* Used for building complete dependency graph and admin queries.
* ARCHITECTURE: Use only for graph initialization (DependencyHandler.create())
* For user queries, use findAll() with pagination instead.
*
* Note: This is a full table scan - use sparingly in production.
* Consider caching the result for graph construction.
* This is a full table scan - use sparingly in production.
*
* @returns Result containing array of all TaskDependency objects or error
* @returns All dependencies ordered by created_at DESC
*
* @example
* ```typescript
* const result = await dependencyRepo.findAll();
* const result = await dependencyRepo.findAllUnbounded();
* if (result.ok) {
* const graph = new DependencyGraph(result.value);
* console.log(`System has ${result.value.length} total dependencies`);
* }
* ```
*/
async findAll(): Promise<Result<readonly TaskDependency[]>> {
async findAllUnbounded(): Promise<Result<readonly TaskDependency[]>> {
return tryCatchAsync(
async () => {
const rows = this.findAllStmt.all() as DependencyRow[];
const rows = this.findAllUnboundedStmt.all() as DependencyRow[];
return rows.map(row => this.rowToDependency(row));
},
operationErrorHandler('find all dependencies')
operationErrorHandler('find all dependencies (unbounded)')
);
}

/**
* Count total dependencies in repository
*
* Useful for pagination UI to display total pages.
*
* @returns Total dependency count
*
* @example
* ```typescript
* const countResult = await dependencyRepo.count();
* const allResult = await dependencyRepo.findAll(100, 0);
* if (countResult.ok && allResult.ok) {
* console.log(`Showing ${allResult.value.length} of ${countResult.value} dependencies`);
* }
* ```
*/
async count(): Promise<Result<number>> {
return tryCatchAsync(
async () => {
const result = this.countStmt.get() as { count: number };
return result.count;
},
operationErrorHandler('count dependencies')
);
}

Expand Down
56 changes: 50 additions & 6 deletions src/implementations/task-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,15 @@ export class SQLiteTaskRepository implements TaskRepository {
private readonly db: SQLite.Database;
private readonly saveStmt: SQLite.Statement;
private readonly findByIdStmt: SQLite.Statement;
private readonly findAllStmt: SQLite.Statement;
private readonly findAllUnboundedStmt: SQLite.Statement;
private readonly findByStatusStmt: SQLite.Statement;
private readonly deleteStmt: SQLite.Statement;
private readonly cleanupOldTasksStmt: SQLite.Statement;
private readonly countStmt: SQLite.Statement;
private readonly findAllPaginatedStmt: SQLite.Statement;

/** Default pagination limit for findAll() */
private static readonly DEFAULT_LIMIT = 100;

constructor(database: Database) {
this.db = database.getDatabase();
Expand Down Expand Up @@ -110,14 +115,22 @@ export class SQLiteTaskRepository implements TaskRepository {
SELECT * FROM tasks WHERE id = ?
`);

this.findAllStmt = this.db.prepare(`
this.findAllUnboundedStmt = this.db.prepare(`
SELECT * FROM tasks ORDER BY created_at DESC
`);

this.findByStatusStmt = this.db.prepare(`
SELECT * FROM tasks WHERE status = ? ORDER BY created_at DESC
`);

this.countStmt = this.db.prepare(`
SELECT COUNT(*) as count FROM tasks
`);

this.findAllPaginatedStmt = this.db.prepare(`
SELECT * FROM tasks ORDER BY created_at DESC LIMIT ? OFFSET ?
`);

this.deleteStmt = this.db.prepare(`
DELETE FROM tasks WHERE id = ?
`);
Expand Down Expand Up @@ -204,16 +217,39 @@ export class SQLiteTaskRepository implements TaskRepository {
);
}

async findAll(): Promise<Result<readonly Task[]>> {
async findAll(limit?: number, offset?: number): Promise<Result<readonly Task[]>> {
return tryCatchAsync(
async () => {
const rows = this.findAllStmt.all() as TaskRow[];
const effectiveLimit = limit ?? SQLiteTaskRepository.DEFAULT_LIMIT;
const effectiveOffset = offset ?? 0;

const rows = this.findAllPaginatedStmt.all(effectiveLimit, effectiveOffset) as TaskRow[];
return rows.map(row => this.rowToTask(row));
},
operationErrorHandler('find all tasks')
);
}

async findAllUnbounded(): Promise<Result<readonly Task[]>> {
return tryCatchAsync(
async () => {
const rows = this.findAllUnboundedStmt.all() as TaskRow[];
return rows.map(row => this.rowToTask(row));
},
operationErrorHandler('find all tasks (unbounded)')
);
}

async count(): Promise<Result<number>> {
return tryCatchAsync(
async () => {
const result = this.countStmt.get() as { count: number };
return result.count;
},
operationErrorHandler('count tasks')
);
}

async findByStatus(status: string): Promise<Result<readonly Task[]>> {
return tryCatchAsync(
async () => {
Expand Down Expand Up @@ -319,8 +355,16 @@ class TransactionTaskRepository implements TaskRepository {
return this.mainRepo.findById(taskId);
}

async findAll(): Promise<Result<readonly Task[]>> {
return this.mainRepo.findAll();
async findAll(limit?: number, offset?: number): Promise<Result<readonly Task[]>> {
return this.mainRepo.findAll(limit, offset);
}

async findAllUnbounded(): Promise<Result<readonly Task[]>> {
return this.mainRepo.findAllUnbounded();
}

async count(): Promise<Result<number>> {
return this.mainRepo.count();
}

async findByStatus(status: string): Promise<Result<readonly Task[]>> {
Expand Down
7 changes: 6 additions & 1 deletion src/services/handlers/dependency-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,13 @@ export class DependencyHandler extends BaseEventHandler {

// PERFORMANCE: Initialize graph eagerly (one-time O(N) cost)
// Subsequent operations use incremental O(1) updates instead of rebuilding
// ARCHITECTURE: Use findAllUnbounded() explicitly - we intentionally need ALL dependencies for graph init
// Full table scan is acceptable here because:
// 1. This runs once at startup, not per-request
// 2. Graph must be complete for cycle detection to work correctly
// 3. Typical dependency count is <1000, scan takes <10ms
handlerLogger.debug('Initializing dependency graph from database');
const allDepsResult = await dependencyRepo.findAll();
const allDepsResult = await dependencyRepo.findAllUnbounded();
if (!allDepsResult.ok) {
handlerLogger.error('Failed to initialize dependency graph', allDepsResult.error);
return err(allDepsResult.error);
Expand Down
4 changes: 3 additions & 1 deletion src/services/handlers/query-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export class QueryHandler extends BaseEventHandler {
}
} else {
// Query all tasks
const tasksResult = await this.repository.findAll();
// ARCHITECTURE: Use findAllUnbounded() to maintain pre-pagination behavior
// TODO: Add pagination support to TaskStatusQuery event (see tech debt issue #31)
const tasksResult = await this.repository.findAllUnbounded();

if (!tasksResult.ok) {
queryResult = tasksResult;
Expand Down
Loading