refactor: add runInTransaction for atomic multi-step DB operations#85
refactor: add runInTransaction for atomic multi-step DB operations#85
Conversation
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant EB as EventBus
participant SH as ScheduleHandler
participant DB as Database.runInTransaction
participant TR as TaskRepository (sync)
participant SR as ScheduleRepository (sync)
EB->>SH: ScheduleTriggered
SH->>SH: resolveAfterScheduleTaskId() [async, outside tx]
SH->>SH: createTask() [pure, outside tx]
SH->>SH: computeScheduleUpdates() [pure, outside tx]
SH->>DB: runInTransaction(() => { ... })
activate DB
DB->>TR: saveSync(task)
DB->>SR: recordExecutionSync(execution)
DB->>SR: updateSync(scheduleId, updates, existing)
Note over DB: COMMIT (all 3 writes atomic)
DB-->>SH: Result<void>
deactivate DB
alt txResult.ok
SH->>SH: logScheduleTransition()
SH->>EB: emit TaskDelegated
SH->>EB: emit ScheduleExecuted
else txResult.err (ROLLBACK)
SH->>SR: recordFailedExecution() [best-effort, outside tx]
end
|
| TaskRepository, | ||
| } from '../../core/interfaces.js'; | ||
| import { err, ok, Result } from '../../core/result.js'; | ||
| import { Database } from '../../implementations/database.js'; |
There was a problem hiding this comment.
🔴 BLOCKING: Concrete Database type in service layer (DIP violation)
The ScheduleHandler depends directly on the concrete Database class instead of an interface. This violates Dependency Inversion Principle and couples the service layer to SQLite/better-sqlite3.
Fix: Extract a TransactionRunner interface in src/core/interfaces.ts:
export interface TransactionRunner {
runInTransaction<T>(fn: () => T): Result<T>;
}Then change ScheduleHandler and HandlerDependencies to depend on TransactionRunner instead of Database.
See architecture review for details.
There was a problem hiding this comment.
Fixed in 1056034 — extracted TransactionRunner interface in src/core/interfaces.ts. Database implements TransactionRunner. ScheduleHandler and handler-setup now depend on the abstraction.
| status: task.status, | ||
| priority: task.priority, | ||
| workingDirectory: task.workingDirectory || null, | ||
| timeout: task.timeout || null, |
There was a problem hiding this comment.
The toDbFormat method uses task.timeout || null which treats 0 as falsy. A timeout of 0 should be valid (immediately time out), but will be stored as null.
Fix:
timeout: task.timeout ?? null,
maxOutputBuffer: task.maxOutputBuffer ?? null,This is pre-existing logic that was refactored into toDbFormat, but the refactoring is a good opportunity to fix it.
There was a problem hiding this comment.
Fixed in 501f952 — all numeric fields (timeout, maxOutputBuffer, retryCount) now use nullish coalescing (??) instead of OR (||).
| */ | ||
| private async updateScheduleAfterTrigger(schedule: Schedule, triggeredAt: number): Promise<Result<void>> { | ||
| const scheduleId = schedule.id; | ||
| private computeScheduleUpdates(schedule: Schedule, triggeredAt: number): Partial<Schedule> { |
There was a problem hiding this comment.
computeScheduleUpdates has side effects
The JSDoc claims "Pure computation — no side effects" but the method calls this.logger.error() and this.logger.info() in three places (lines 562, 577, 588).
Fix: Update the comment to reflect reality:
/**
* Compute schedule update fields after a trigger (runCount, lastRunAt, nextRunAt, status).
* Logs status changes but has no database side effects. Shared by async and sync trigger paths.
*/There was a problem hiding this comment.
Fixed in 1056034 — extracted all logging from computeScheduleUpdates into logScheduleTransition() which is called post-commit. JSDoc now accurately reads "Pure computation — no side effects."
| * @param options - Optional configuration | ||
| * @returns Result containing initialized handler or error | ||
| */ | ||
| static async create( |
There was a problem hiding this comment.
🔴 BLOCKING: Factory method parameter ordering breaks convention
The ScheduleHandler.create() method places database after logger, breaking the established pattern used in other handlers. The convention is (repos..., eventBus, logger, options?).
Fix: Reorder parameters to match convention:
static create(
scheduleRepo: ScheduleRepository & SyncScheduleOperations,
taskRepo: TaskRepository & SyncTaskOperations,
eventBus: TaskEventEmitter,
database: Database,
logger: TaskLogger,
options?: ScheduleHandlerOptions,
): Result<ScheduleHandler>The constructor already has this order; just match it in create().
There was a problem hiding this comment.
Fixed in 1056034 — factory create() params reordered to match constructor: (repos, eventBus, database, logger, options?). Callers in handler-setup.ts and tests updated.
| return this.rowToSchedule(row); | ||
| } | ||
|
|
||
| updateSync(id: ScheduleId, update: Partial<Schedule>): void { |
There was a problem hiding this comment.
The updateSync method calls findByIdSync to read the full schedule row, then updates it. But inside handleSingleTaskTrigger and handlePipelineTrigger, the schedule was already fetched at the top of the handler.
Fix: Consider adding an updateFieldsSync(id, mergedSchedule) method that skips the read when the caller already has the full object. This is an optimization opportunity, not blocking.
There was a problem hiding this comment.
Fixed in 1056034 — updateSync now accepts optional existing?: Schedule parameter. Both trigger paths pass the already-fetched schedule, skipping the redundant findByIdSync call inside the transaction.
Summary of Code Review FindingsComprehensive review across 8 reporters (security, architecture, performance, complexity, consistency, regression, tests, typescript) identified 7 blocking and 6 should-fix issues. Inline comments created for specific file:line findings. BLOCKING Issues Requiring Action
SHOULD-FIX Issues
Review Scores
RecommendationCHANGES_REQUESTED — Address blocking issues (DIP, parameter ordering, dead code) before merge. Should-fix items are recommended but lower priority. Generated by Claude Code review-pr operation | 8 review reports analyzed |
Replace manual cleanup loops with database-level transaction guarantees. Schedule handler trigger methods now wrap task saves, execution records, and schedule updates in a single atomic transaction — partial failures roll back cleanly instead of leaving orphaned rows. - Add Database.runInTransaction() exposing better-sqlite3 transactions - Add SyncTaskOperations / SyncScheduleOperations interfaces - Add sync method variants (saveSync, updateSync, findByIdSync, recordExecutionSync) - Extract toDbFormat() helpers in both repositories (DRY async + sync) - Refactor handleSingleTaskTrigger: 3-step sequence → single transaction - Refactor handlePipelineTrigger: N saves + cleanup loop → atomic transaction - Remove broken TaskRepository.transaction() + TransactionTaskRepository - Fix handler-setup.test.ts: add missing dependency registrations, include in test:services - 14 files changed, 680 insertions, 317 deletions
- Make `database` parameter required in ScheduleHandler.create() (was optional with runtime guard — now caught at compile time) - Change error prefix from "Failed to create task:" to "Schedule trigger failed:" since the transaction can fail at any step
Replace || with ?? for timeout and maxOutputBuffer in both toDbFormat() and rowToTask() so that a value of 0 is preserved instead of being treated as falsy. Matches the existing ?? pattern used for exitCode. Co-Authored-By: Claude <noreply@anthropic.com>
Remove two private methods with zero callers that were superseded by the sync transaction path: recordTriggeredExecution() and updateScheduleAfterTrigger(). Fix computeScheduleUpdates() JSDoc that incorrectly claimed "no side effects" when the method performs logging. Co-Authored-By: Claude <noreply@anthropic.com>
Unify transaction patterns — DependencyRepository.addDependencies() now uses the shared runInTransaction() helper instead of raw db.transaction() with a manual tryCatch wrapper, completing the #81 migration.
retryCount is numeric — || incorrectly treats 0 as falsy.
4e2d9d5 to
3b53a89
Compare
…ull guards - Extract TransactionRunner interface so handlers depend on abstraction, not concrete Database (fixes DIP violation) - Align factory create() param order with constructor (database before logger) - Add optional `existing` param to updateSync to skip redundant DB read - Add null guards on post-insert row lookup in recordExecutionSync (sync+async) - Move logging out of computeScheduleUpdates into post-commit logScheduleTransition - Add inner try-catch in single-task transaction to prevent double-wrapped errors - Fix freshDb test to use repos bound to the same database instance
Summary
Database.runInTransaction()exposing better-sqlite3's synchronous transaction semanticsSyncTaskOperations/SyncScheduleOperationsinterfaces with sync method variants (saveSync,updateSync,findByIdSync,recordExecutionSync)handleSingleTaskTriggerandhandlePipelineTriggernow wrap all DB mutations in a single atomic transaction — partial failures roll back cleanly instead of leaving orphaned rowsTaskRepository.transaction()andTransactionTaskRepository(superseded byrunInTransaction)handler-setup.test.ts(missing dependency registrations, now included intest:services)Closes #81
Key behavior change
Pipeline partial save failure: previously left N saved-then-cancelled tasks in the DB. Now leaves 0 tasks (transaction rollback). No orphaned rows, no manual cleanup loop.
Follow-up items (tracked separately)
handleMissedRunatomicity (tech-debt, low risk)Test plan
npm run build— TypeScript compiles cleannpm run test:implementations— 310 tests (new: runInTransaction, sync methods)npm run test:handlers— 122 tests (new: atomicity, rollback behavior)npm run test:services— 152 tests (new: handler-setup with database dep)npm run test:repositories— 127 testsnpm run test:core— 367 testsnpm run test:adapters— 55 testsnpm run test:cli— 150 tests