(events: readonly TaskLedgerEvent[])
| 554 | } |
| 555 | |
| 556 | export function projectTaskLedgerEvents(events: readonly TaskLedgerEvent[]): TaskLedgerProjection { |
| 557 | const tasks = new Map<string, TaskLedgerEventTaskSnapshot>(); |
| 558 | const firstSeen = new Map<string, number>(); |
| 559 | const diagnostics: string[] = []; |
| 560 | for (let eventIndex = 0; eventIndex < events.length; eventIndex += 1) { |
| 561 | const event = events[eventIndex]!; |
| 562 | if (!isTaskLedgerEvent(event)) { |
| 563 | diagnostics.push('invalid task ledger event shape'); |
| 564 | continue; |
| 565 | } |
| 566 | const current = tasks.get(event.taskId); |
| 567 | const typeDiagnostic = validateTaskLedgerEventType(event, current); |
| 568 | if (typeDiagnostic) diagnostics.push(typeDiagnostic); |
| 569 | if (event.type === 'task_created' || event.type === 'task_imported') { |
| 570 | if (current) diagnostics.push(`duplicate ${event.type} for ${event.taskId}`); |
| 571 | if (!firstSeen.has(event.taskId)) firstSeen.set(event.taskId, eventIndex); |
| 572 | tasks.set(event.taskId, { ...event.task }); |
| 573 | continue; |
| 574 | } |
| 575 | if (!current) { |
| 576 | diagnostics.push(`task event ${event.type} references unknown task ${event.taskId}`); |
| 577 | if (!firstSeen.has(event.taskId)) firstSeen.set(event.taskId, eventIndex); |
| 578 | tasks.set(event.taskId, { ...event.task }); |
| 579 | continue; |
| 580 | } |
| 581 | if (event.previousStatus !== undefined && current.status !== event.previousStatus) { |
| 582 | diagnostics.push( |
| 583 | `task event ${event.type} for ${event.taskId} expected previous status ${event.previousStatus} but saw ${current.status}`, |
| 584 | ); |
| 585 | } |
| 586 | if ( |
| 587 | !canTransitionTaskStatus(current.status, event.nextStatus, { |
| 588 | explicitReopen: event.type === 'task_reopened', |
| 589 | }) |
| 590 | ) { |
| 591 | diagnostics.push( |
| 592 | `invalid task transition ${current.status} -> ${event.nextStatus} for ${event.taskId}`, |
| 593 | ); |
| 594 | } |
| 595 | tasks.set(event.taskId, { ...event.task }); |
| 596 | } |
| 597 | const hydrated = hydrateTaskLedger([...tasks.values()], firstSeen, diagnostics); |
| 598 | return { tasks: hydrated.tasks, diagnostics, backfilledTaskIds: hydrated.backfilledTaskIds }; |
| 599 | } |
| 600 | |
| 601 | function validateTaskLedgerEventType( |
| 602 | event: TaskLedgerEvent, |
no test coverage detected