@sprintdock/backend 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +252 -0
  3. package/SERVER.md +25 -0
  4. package/dist/index.d.ts +1536 -0
  5. package/dist/index.js +4103 -0
  6. package/drizzle/0000_fresh_roxanne_simpson.sql +51 -0
  7. package/drizzle/0001_sprint_markdown_content.sql +1 -0
  8. package/drizzle/0002_task_touched_files.sql +8 -0
  9. package/drizzle/meta/0000_snapshot.json +372 -0
  10. package/drizzle/meta/0001_snapshot.json +379 -0
  11. package/drizzle/meta/_journal.json +27 -0
  12. package/drizzle.config.ts +14 -0
  13. package/package.json +40 -0
  14. package/src/application/container.ts +44 -0
  15. package/src/application/dto/plan-sprint-analytics.dto.ts +30 -0
  16. package/src/application/plan.service.ts +123 -0
  17. package/src/application/sprint.service.ts +118 -0
  18. package/src/application/task.service.ts +389 -0
  19. package/src/db/connection.ts +25 -0
  20. package/src/db/migrator.ts +46 -0
  21. package/src/db/schema/index.ts +14 -0
  22. package/src/db/schema/plans.ts +18 -0
  23. package/src/db/schema/relations.ts +36 -0
  24. package/src/db/schema/sprints.ts +33 -0
  25. package/src/db/schema/tasks.ts +62 -0
  26. package/src/domain/entities/index.ts +30 -0
  27. package/src/domain/entities/plan.entity.ts +33 -0
  28. package/src/domain/entities/sprint.entity.ts +44 -0
  29. package/src/domain/entities/task.entity.ts +80 -0
  30. package/src/domain/repositories/index.ts +9 -0
  31. package/src/domain/repositories/plan.repository.ts +21 -0
  32. package/src/domain/repositories/sprint.repository.ts +19 -0
  33. package/src/domain/repositories/task.repository.ts +35 -0
  34. package/src/domain/services/index.ts +9 -0
  35. package/src/domain/services/plan-domain.service.ts +44 -0
  36. package/src/domain/services/sprint-domain.service.ts +44 -0
  37. package/src/domain/services/task-domain.service.ts +136 -0
  38. package/src/errors/backend-errors.ts +75 -0
  39. package/src/http/app-factory.ts +55 -0
  40. package/src/http/controllers/health.controller.ts +33 -0
  41. package/src/http/controllers/plan.controller.ts +153 -0
  42. package/src/http/controllers/sprint.controller.ts +111 -0
  43. package/src/http/controllers/task.controller.ts +158 -0
  44. package/src/http/express-augmentation.d.ts +20 -0
  45. package/src/http/middleware/cors.ts +41 -0
  46. package/src/http/middleware/error-handler.ts +50 -0
  47. package/src/http/middleware/request-id.ts +28 -0
  48. package/src/http/middleware/validate.ts +54 -0
  49. package/src/http/routes/v1/index.ts +39 -0
  50. package/src/http/routes/v1/plan.routes.ts +51 -0
  51. package/src/http/routes/v1/schemas.ts +175 -0
  52. package/src/http/routes/v1/sprint.routes.ts +49 -0
  53. package/src/http/routes/v1/task.routes.ts +64 -0
  54. package/src/index.ts +34 -0
  55. package/src/infrastructure/observability/audit-log.ts +34 -0
  56. package/src/infrastructure/observability/request-correlation.ts +20 -0
  57. package/src/infrastructure/repositories/drizzle/drizzle-plan.repository.ts +138 -0
  58. package/src/infrastructure/repositories/drizzle/drizzle-sprint.repository.ts +137 -0
  59. package/src/infrastructure/repositories/drizzle/drizzle-task.repository.ts +403 -0
  60. package/src/infrastructure/repositories/drizzle/index.ts +16 -0
  61. package/src/infrastructure/repositories/drizzle/row-mappers.ts +106 -0
  62. package/src/infrastructure/repositories/drizzle/sqlite-db.ts +13 -0
  63. package/src/infrastructure/repositories/repository-factory.ts +54 -0
  64. package/src/infrastructure/security/auth-context.ts +35 -0
  65. package/src/infrastructure/security/input-guard.ts +21 -0
  66. package/src/infrastructure/security/rate-limiter.ts +65 -0
  67. package/src/mcp/bootstrap-sprintdock-sqlite.ts +45 -0
  68. package/src/mcp/mcp-query-helpers.ts +89 -0
  69. package/src/mcp/mcp-text-formatters.ts +204 -0
  70. package/src/mcp/mcp-tool-error.ts +24 -0
  71. package/src/mcp/plugins/context-tools.plugin.ts +107 -0
  72. package/src/mcp/plugins/default-plugins.ts +23 -0
  73. package/src/mcp/plugins/index.ts +21 -0
  74. package/src/mcp/plugins/mcp-tool-kit.ts +90 -0
  75. package/src/mcp/plugins/plan-tools.plugin.ts +426 -0
  76. package/src/mcp/plugins/sprint-tools.plugin.ts +396 -0
  77. package/src/mcp/plugins/task-tools.plugin.ts +528 -0
  78. package/src/mcp/plugins/task-workflow.plugin.ts +275 -0
  79. package/src/mcp/plugins/types.ts +45 -0
  80. package/src/mcp/register-sprintdock-mcp-tools.ts +50 -0
  81. package/src/mcp/sprintdock-mcp-capabilities.ts +14 -0
  82. package/src/mcp/sprintdock-mcp-runtime.ts +119 -0
  83. package/src/mcp/tool-guard.ts +58 -0
  84. package/src/mcp/transports/http-app-factory.ts +31 -0
  85. package/src/mcp/transports/http-entry.ts +27 -0
  86. package/src/mcp/transports/stdio-entry.ts +17 -0
  87. package/tests/application/container.test.ts +36 -0
  88. package/tests/application/plan.service.test.ts +114 -0
  89. package/tests/application/sprint.service.test.ts +138 -0
  90. package/tests/application/task.service.test.ts +325 -0
  91. package/tests/db/test-db.test.ts +112 -0
  92. package/tests/domain/plan-domain.service.test.ts +44 -0
  93. package/tests/domain/sprint-domain.service.test.ts +38 -0
  94. package/tests/domain/task-domain.service.test.ts +105 -0
  95. package/tests/errors/backend-errors.test.ts +44 -0
  96. package/tests/helpers/test-db.ts +43 -0
  97. package/tests/http/error-handler.test.ts +37 -0
  98. package/tests/http/plan.routes.test.ts +128 -0
  99. package/tests/http/sprint.routes.test.ts +72 -0
  100. package/tests/http/task.routes.test.ts +130 -0
  101. package/tests/http/test-app.ts +17 -0
  102. package/tests/infrastructure/drizzle-plan.repository.test.ts +62 -0
  103. package/tests/infrastructure/drizzle-sprint.repository.test.ts +49 -0
  104. package/tests/infrastructure/drizzle-task.repository.test.ts +132 -0
  105. package/tests/mcp/mcp-text-formatters.test.ts +246 -0
  106. package/tests/mcp/register-sprintdock-mcp-tools.test.ts +207 -0
  107. package/tsconfig.json +9 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Application service orchestrating sprint operations within a plan.
5
+ */
6
+ import { NotFoundError } from "../errors/backend-errors.js";
7
+ import type {
8
+ CreateSprintInput,
9
+ Sprint,
10
+ SprintStatus,
11
+ UpdateSprintInput
12
+ } from "../domain/entities/sprint.entity";
13
+ import type { PlanRepository } from "../domain/repositories/plan.repository";
14
+ import type { SprintRepository } from "../domain/repositories/sprint.repository";
15
+ import type { SprintDomainService } from "../domain/services/sprint-domain.service";
16
+ import type { PlanService } from "./plan.service";
17
+
18
+ /**
19
+ * Coordinates sprint persistence, plan resolution, and sprint lifecycle rules.
20
+ */
21
+ export class SprintService {
22
+ public constructor(
23
+ private readonly sprintRepo: SprintRepository,
24
+ private readonly planRepo: PlanRepository,
25
+ private readonly planService: PlanService,
26
+ private readonly sprintDomain: SprintDomainService
27
+ ) {}
28
+
29
+ /** @param planIdOrSlug When omitted, the active plan is used. */
30
+ public async listSprints(planIdOrSlug?: string): Promise<Sprint[]> {
31
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
32
+ return this.sprintRepo.listByPlanId(plan.id);
33
+ }
34
+
35
+ /**
36
+ * Creates a sprint under the resolved plan; {@link CreateSprintInput.planId} is overwritten.
37
+ */
38
+ public async createSprint(
39
+ planIdOrSlug: string,
40
+ input: CreateSprintInput
41
+ ): Promise<Sprint> {
42
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
43
+ return this.sprintRepo.create({ ...input, planId: plan.id });
44
+ }
45
+
46
+ /**
47
+ * @throws NotFoundError when the sprint does not exist.
48
+ */
49
+ public async getSprint(id: string): Promise<Sprint> {
50
+ const sprint = await this.sprintRepo.findById(id);
51
+ if (!sprint) {
52
+ throw new NotFoundError("Sprint", id);
53
+ }
54
+ return sprint;
55
+ }
56
+
57
+ /**
58
+ * Finds a sprint by plan scope and slug.
59
+ *
60
+ * @throws NotFoundError when the sprint is missing.
61
+ */
62
+ public async getSprintBySlug(
63
+ planIdOrSlug: string,
64
+ sprintSlug: string
65
+ ): Promise<Sprint> {
66
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
67
+ const sprint = await this.sprintRepo.findBySlug(plan.id, sprintSlug);
68
+ if (!sprint) {
69
+ throw new NotFoundError("Sprint", sprintSlug);
70
+ }
71
+ return sprint;
72
+ }
73
+
74
+ /**
75
+ * Updates sprint fields (name, goal, dates, order) without changing status.
76
+ * Use {@link updateSprintStatus} for lifecycle transitions.
77
+ */
78
+ public async updateSprint(
79
+ id: string,
80
+ input: Pick<
81
+ UpdateSprintInput,
82
+ "name" | "goal" | "markdownContent" | "startDate" | "endDate" | "order"
83
+ >
84
+ ): Promise<Sprint> {
85
+ const existing = await this.getSprint(id);
86
+ const updated = await this.sprintRepo.update(existing.id, input);
87
+ if (!updated) {
88
+ throw new NotFoundError("Sprint", id);
89
+ }
90
+ return updated;
91
+ }
92
+
93
+ /**
94
+ * Updates sprint status after validating the transition.
95
+ */
96
+ public async updateSprintStatus(
97
+ id: string,
98
+ status: SprintStatus
99
+ ): Promise<Sprint> {
100
+ const existing = await this.getSprint(id);
101
+ this.sprintDomain.validateTransition(existing.status, status);
102
+ const updated = await this.sprintRepo.update(existing.id, { status });
103
+ if (!updated) {
104
+ throw new NotFoundError("Sprint", id);
105
+ }
106
+ return updated;
107
+ }
108
+
109
+ /**
110
+ * @throws NotFoundError when the sprint does not exist.
111
+ */
112
+ public async deleteSprint(id: string): Promise<void> {
113
+ const removed = await this.sprintRepo.delete(id);
114
+ if (!removed) {
115
+ throw new NotFoundError("Sprint", id);
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Application service orchestrating task operations within plans and sprints.
5
+ */
6
+ import { NotFoundError, ValidationError } from "../errors/backend-errors.js";
7
+ import type { Plan } from "../domain/entities/plan.entity";
8
+ import type {
9
+ CreateTaskInput,
10
+ Task,
11
+ TaskDependency,
12
+ TaskStatus,
13
+ UpdateTaskInput
14
+ } from "../domain/entities/task.entity";
15
+ import type { SprintRepository } from "../domain/repositories/sprint.repository";
16
+ import type {
17
+ TaskFilter,
18
+ TaskRepository
19
+ } from "../domain/repositories/task.repository";
20
+ import type { TaskDomainService } from "../domain/services/task-domain.service";
21
+ import type { PlanSprintTaskAnalyticsDto } from "./dto/plan-sprint-analytics.dto.js";
22
+ import type { PlanService } from "./plan.service";
23
+
24
+ const PENDING_TASK_ID = "__pending__";
25
+
26
+ function emptyStatusCounts(): Record<TaskStatus, number> {
27
+ return { todo: 0, in_progress: 0, blocked: 0, done: 0 };
28
+ }
29
+
30
+ /**
31
+ * Field updates for {@link TaskService.updateTask}
32
+ * (status changes use {@link updateTaskStatus}).
33
+ */
34
+ export type TaskFieldUpdateInput = Omit<UpdateTaskInput, "status">;
35
+
36
+ /**
37
+ * Coordinates task persistence, plan resolution, ordering, and dependency validation.
38
+ */
39
+ export class TaskService {
40
+ public constructor(
41
+ private readonly taskRepo: TaskRepository,
42
+ private readonly sprintRepo: SprintRepository,
43
+ private readonly planService: PlanService,
44
+ private readonly taskDomain: TaskDomainService
45
+ ) {}
46
+
47
+ /**
48
+ * Lists tasks for a sprint, or all tasks in a resolved plan when {@link sprintId} is omitted.
49
+ */
50
+ public async listTasks(
51
+ sprintId?: string,
52
+ planIdOrSlug?: string,
53
+ filter?: TaskFilter
54
+ ): Promise<Task[]> {
55
+ if (sprintId !== undefined && sprintId !== "") {
56
+ return this.taskRepo.listBySprintId(sprintId, filter);
57
+ }
58
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
59
+ return this.taskRepo.listByPlanId(plan.id, filter);
60
+ }
61
+
62
+ /**
63
+ * Creates a task after validating sprint ownership, order uniqueness, and dependencies.
64
+ */
65
+ public async createTask(
66
+ input: CreateTaskInput,
67
+ planIdOrSlug?: string
68
+ ): Promise<Task> {
69
+ const sprint = await this.sprintRepo.findById(input.sprintId);
70
+ if (!sprint) {
71
+ throw new NotFoundError("Sprint", input.sprintId);
72
+ }
73
+
74
+ let plan: Plan;
75
+ if (planIdOrSlug !== undefined && planIdOrSlug !== "") {
76
+ plan = await this.planService.resolvePlan(planIdOrSlug);
77
+ if (sprint.planId !== plan.id) {
78
+ throw new ValidationError(
79
+ "Sprint does not belong to the resolved plan"
80
+ );
81
+ }
82
+ } else {
83
+ plan = await this.planService.getPlan(sprint.planId);
84
+ }
85
+
86
+ const sprintTasks = await this.taskRepo.listBySprintId(input.sprintId);
87
+ let order: number;
88
+ if (input.order === undefined) {
89
+ order =
90
+ sprintTasks.length === 0
91
+ ? 0
92
+ : Math.max(...sprintTasks.map((t) => t.order)) + 1;
93
+ } else {
94
+ order = input.order;
95
+ }
96
+
97
+ const pendingTask: Task = {
98
+ id: PENDING_TASK_ID,
99
+ sprintId: input.sprintId,
100
+ title: input.title,
101
+ description: input.description ?? null,
102
+ status: "todo",
103
+ priority: input.priority,
104
+ order,
105
+ assignee: input.assignee ?? null,
106
+ tags: input.tags ?? null,
107
+ touchedFiles: [],
108
+ createdAt: "",
109
+ updatedAt: ""
110
+ };
111
+ this.taskDomain.validateOrderUniqueness([...sprintTasks, pendingTask]);
112
+
113
+ const planTasks = await this.taskRepo.listByPlanId(plan.id);
114
+ const prereqs = input.dependsOnTaskIds ?? [];
115
+ if (prereqs.length > 0) {
116
+ this.taskDomain.validateDependencyReferences(
117
+ prereqs,
118
+ planTasks.map((t) => t.id)
119
+ );
120
+ const existingDeps: TaskDependency[] = [];
121
+ for (const t of planTasks) {
122
+ existingDeps.push(...(await this.taskRepo.getDependencies(t.id)));
123
+ }
124
+ const newEdges = prereqs.map((dependsOnTaskId) => ({
125
+ taskId: PENDING_TASK_ID,
126
+ dependsOnTaskId
127
+ }));
128
+ this.taskDomain.validateDependencyGraph(
129
+ [...planTasks, pendingTask],
130
+ [...existingDeps, ...newEdges]
131
+ );
132
+ }
133
+
134
+ return this.taskRepo.create({
135
+ ...input,
136
+ order
137
+ });
138
+ }
139
+
140
+ /**
141
+ * @throws NotFoundError when the task does not exist.
142
+ */
143
+ public async getTask(id: string): Promise<Task> {
144
+ const task = await this.taskRepo.findById(id);
145
+ if (!task) {
146
+ throw new NotFoundError("Task", id);
147
+ }
148
+ return task;
149
+ }
150
+
151
+ /**
152
+ * Updates title, description, priority, tags, or order (not status).
153
+ */
154
+ public async updateTask(
155
+ id: string,
156
+ input: TaskFieldUpdateInput
157
+ ): Promise<Task> {
158
+ const existing = await this.getTask(id);
159
+ const merged: Task = {
160
+ ...existing,
161
+ title: input.title ?? existing.title,
162
+ description:
163
+ input.description !== undefined
164
+ ? input.description
165
+ : existing.description,
166
+ priority: input.priority ?? existing.priority,
167
+ order: input.order ?? existing.order,
168
+ assignee:
169
+ input.assignee !== undefined ? input.assignee : existing.assignee,
170
+ tags: input.tags !== undefined ? input.tags : existing.tags,
171
+ touchedFiles: existing.touchedFiles
172
+ };
173
+ if (input.order !== undefined && input.order !== existing.order) {
174
+ const sprintTasks = await this.taskRepo.listBySprintId(existing.sprintId);
175
+ this.taskDomain.validateOrderUniqueness(
176
+ sprintTasks.map((t) => (t.id === id ? merged : t))
177
+ );
178
+ }
179
+ const updated = await this.taskRepo.update(existing.id, input);
180
+ if (!updated) {
181
+ throw new NotFoundError("Task", id);
182
+ }
183
+ return updated;
184
+ }
185
+
186
+ /**
187
+ * Replaces prerequisite edges for a task after validating the dependency DAG.
188
+ */
189
+ public async setTaskDependencies(
190
+ taskId: string,
191
+ dependsOnTaskIds: string[],
192
+ planIdOrSlug?: string
193
+ ): Promise<void> {
194
+ const task = await this.getTask(taskId);
195
+ const sprint = await this.sprintRepo.findById(task.sprintId);
196
+ if (!sprint) {
197
+ throw new NotFoundError("Sprint", task.sprintId);
198
+ }
199
+ const plan = await this.planService.getPlan(sprint.planId);
200
+ if (planIdOrSlug !== undefined && planIdOrSlug !== "") {
201
+ const scoped = await this.planService.resolvePlan(planIdOrSlug);
202
+ if (scoped.id !== plan.id) {
203
+ throw new ValidationError(
204
+ "Task does not belong to the resolved plan"
205
+ );
206
+ }
207
+ }
208
+ const planTasks = await this.taskRepo.listByPlanId(plan.id);
209
+ this.taskDomain.validateDependencyReferences(
210
+ dependsOnTaskIds,
211
+ planTasks.map((t) => t.id)
212
+ );
213
+ const existingDeps: TaskDependency[] = [];
214
+ for (const t of planTasks) {
215
+ if (t.id === taskId) {
216
+ continue;
217
+ }
218
+ existingDeps.push(...(await this.taskRepo.getDependencies(t.id)));
219
+ }
220
+ const newEdges = dependsOnTaskIds.map((dependsOnTaskId) => ({
221
+ taskId,
222
+ dependsOnTaskId
223
+ }));
224
+ this.taskDomain.validateDependencyGraph(planTasks, [
225
+ ...existingDeps,
226
+ ...newEdges
227
+ ]);
228
+ await this.taskRepo.setDependencies(taskId, dependsOnTaskIds);
229
+ }
230
+
231
+ /**
232
+ * Loads prerequisite tasks and tasks that depend on this task.
233
+ */
234
+ public async getTaskDependencyInfo(
235
+ taskId: string,
236
+ planIdOrSlug?: string
237
+ ): Promise<{ dependsOn: Task[]; dependedOnBy: Task[] }> {
238
+ const task = await this.getTask(taskId);
239
+ if (planIdOrSlug !== undefined && planIdOrSlug !== "") {
240
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
241
+ const sprint = await this.sprintRepo.findById(task.sprintId);
242
+ if (!sprint || sprint.planId !== plan.id) {
243
+ throw new ValidationError(
244
+ "Task does not belong to the resolved plan"
245
+ );
246
+ }
247
+ }
248
+ const depRows = await this.taskRepo.getDependencies(taskId);
249
+ const dependsOn: Task[] = [];
250
+ for (const d of depRows) {
251
+ dependsOn.push(await this.getTask(d.dependsOnTaskId));
252
+ }
253
+ const dependentIds = await this.taskRepo.listDependentTaskIds(taskId);
254
+ const dependedOnBy: Task[] = [];
255
+ for (const id of dependentIds) {
256
+ dependedOnBy.push(await this.getTask(id));
257
+ }
258
+ return { dependsOn, dependedOnBy };
259
+ }
260
+
261
+ /**
262
+ * Updates task status after validating the transition.
263
+ */
264
+ public async updateTaskStatus(id: string, status: TaskStatus): Promise<Task> {
265
+ const existing = await this.getTask(id);
266
+ this.taskDomain.validateTransition(existing.status, status);
267
+ const updated = await this.taskRepo.update(existing.id, { status });
268
+ if (!updated) {
269
+ throw new NotFoundError("Task", id);
270
+ }
271
+ return updated;
272
+ }
273
+
274
+ /**
275
+ * Sets or clears the task assignee.
276
+ */
277
+ public async assignTask(
278
+ id: string,
279
+ assignee: string | null
280
+ ): Promise<Task> {
281
+ const existing = await this.getTask(id);
282
+ const updated = await this.taskRepo.update(existing.id, { assignee });
283
+ if (!updated) {
284
+ throw new NotFoundError("Task", id);
285
+ }
286
+ return updated;
287
+ }
288
+
289
+ /**
290
+ * Moves a task to another sprint in the same plan.
291
+ */
292
+ public async moveTask(taskId: string, targetSprintId: string): Promise<Task> {
293
+ const task = await this.getTask(taskId);
294
+ const sourceSprint = await this.sprintRepo.findById(task.sprintId);
295
+ const targetSprint = await this.sprintRepo.findById(targetSprintId);
296
+ if (!sourceSprint) {
297
+ throw new NotFoundError("Sprint", task.sprintId);
298
+ }
299
+ if (!targetSprint) {
300
+ throw new NotFoundError("Sprint", targetSprintId);
301
+ }
302
+ if (sourceSprint.planId !== targetSprint.planId) {
303
+ throw new ValidationError(
304
+ "Cannot move a task to a sprint in a different plan"
305
+ );
306
+ }
307
+ const moved = await this.taskRepo.move(taskId, targetSprintId);
308
+ if (!moved) {
309
+ throw new NotFoundError("Task", taskId);
310
+ }
311
+ return moved;
312
+ }
313
+
314
+ /**
315
+ * @throws NotFoundError when the task does not exist.
316
+ */
317
+ public async deleteTask(id: string): Promise<void> {
318
+ const removed = await this.taskRepo.delete(id);
319
+ if (!removed) {
320
+ throw new NotFoundError("Task", id);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Loads all sprints for the plan (ordered) and task counts by status per sprint, plus plan rollup.
326
+ */
327
+ public async getPlanSprintTaskAnalytics(
328
+ planIdOrSlug: string
329
+ ): Promise<PlanSprintTaskAnalyticsDto> {
330
+ const plan = await this.planService.resolvePlan(planIdOrSlug);
331
+ const [sprintList, tasks] = await Promise.all([
332
+ this.sprintRepo.listByPlanId(plan.id),
333
+ this.taskRepo.listByPlanId(plan.id)
334
+ ]);
335
+ const perSprint = new Map<string, Record<TaskStatus, number>>();
336
+ for (const s of sprintList) {
337
+ perSprint.set(s.id, emptyStatusCounts());
338
+ }
339
+ const rollup = emptyStatusCounts();
340
+ for (const t of tasks) {
341
+ rollup[t.status] += 1;
342
+ const row = perSprint.get(t.sprintId);
343
+ if (row !== undefined) {
344
+ row[t.status] += 1;
345
+ }
346
+ }
347
+ const sprints: PlanSprintTaskAnalyticsDto["sprints"] = sprintList.map(
348
+ (sprint) => {
349
+ const c = perSprint.get(sprint.id) ?? emptyStatusCounts();
350
+ const totalTasks = c.todo + c.in_progress + c.blocked + c.done;
351
+ return {
352
+ sprint,
353
+ tasksByStatus: { ...c },
354
+ totalTasks
355
+ };
356
+ }
357
+ );
358
+ const totalTasks =
359
+ rollup.todo +
360
+ rollup.in_progress +
361
+ rollup.blocked +
362
+ rollup.done;
363
+ return {
364
+ planId: plan.id,
365
+ sprints,
366
+ rollup: {
367
+ tasksByStatus: { ...rollup },
368
+ totalTasks,
369
+ sprintCount: sprintList.length
370
+ }
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Fleet Command throughput: counts across all tasks (all plans). Blocked tasks are omitted from the trio.
376
+ */
377
+ public async getGlobalTaskThroughput(): Promise<{
378
+ todo: number;
379
+ inProgress: number;
380
+ done: number;
381
+ }> {
382
+ const c = await this.taskRepo.countByStatusGlobally();
383
+ return {
384
+ todo: c.todo,
385
+ inProgress: c.in_progress,
386
+ done: c.done
387
+ };
388
+ }
389
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: SQLite connection factory wrapping better-sqlite3 with Drizzle ORM.
5
+ */
6
+ import Database from "better-sqlite3";
7
+ import { drizzle } from "drizzle-orm/better-sqlite3";
8
+ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
9
+ import * as schema from "./schema/index";
10
+
11
+ /**
12
+ * Opens a SQLite database at the given path and returns a Drizzle client.
13
+ *
14
+ * @param dbPath Filesystem path or `:memory:` for an ephemeral database.
15
+ * @returns Drizzle database instance bound to the canonical schema.
16
+ */
17
+ export function createSqliteConnection(
18
+ dbPath: string
19
+ ): BetterSQLite3Database<typeof schema> {
20
+ const sqlite = new Database(dbPath);
21
+ sqlite.pragma("journal_mode = WAL");
22
+ sqlite.pragma("foreign_keys = ON");
23
+ sqlite.pragma("busy_timeout = 5000");
24
+ return drizzle(sqlite, { schema });
25
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Applies Drizzle SQL migrations from the package drizzle/ folder.
5
+ */
6
+ import { existsSync } from "node:fs";
7
+ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
8
+ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import * as schema from "./schema/index";
12
+
13
+ /**
14
+ * Resolves `packages/backend/drizzle` whether this module runs from source (`src/db/migrator.ts`)
15
+ * or from the bundled entry (`dist/index.js`), where `../../drizzle` would escape the package.
16
+ */
17
+ function resolveMigrationsFolder(): string {
18
+ let dir = dirname(fileURLToPath(import.meta.url));
19
+ for (let i = 0; i < 8; i++) {
20
+ const candidate = join(dir, "drizzle");
21
+ if (existsSync(join(candidate, "meta", "_journal.json"))) {
22
+ return candidate;
23
+ }
24
+ const parent = dirname(dir);
25
+ if (parent === dir) {
26
+ break;
27
+ }
28
+ dir = parent;
29
+ }
30
+ throw new Error(
31
+ "Sprintdock: could not find drizzle migrations (expected drizzle/meta/_journal.json next to @sprintdock/backend)."
32
+ );
33
+ }
34
+
35
+ const migrationsFolder = resolveMigrationsFolder();
36
+
37
+ /**
38
+ * Runs all pending migrations against the given database.
39
+ *
40
+ * @param db Drizzle client created via {@link createSqliteConnection}.
41
+ */
42
+ export function runMigrations(
43
+ db: BetterSQLite3Database<typeof schema>
44
+ ): void {
45
+ migrate(db, { migrationsFolder });
46
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Re-exports all Drizzle tables and relations for the migrator and ORM.
5
+ */
6
+ export { plans } from "./plans";
7
+ export { sprints } from "./sprints";
8
+ export { taskDependencies, taskTouchedFiles, tasks } from "./tasks";
9
+ export {
10
+ plansRelations,
11
+ sprintsRelations,
12
+ taskTouchedFilesRelations,
13
+ tasksRelations
14
+ } from "./relations";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Drizzle schema for the plans table (plan-tree root entity).
5
+ */
6
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
7
+
8
+ export const plans = sqliteTable("plans", {
9
+ id: text("id").primaryKey(),
10
+ slug: text("slug").notNull().unique(),
11
+ title: text("title").notNull(),
12
+ description: text("description"),
13
+ markdownContent: text("markdown_content"),
14
+ status: text("status").notNull(),
15
+ isActive: integer("is_active", { mode: "boolean" }).notNull().default(false),
16
+ createdAt: text("created_at").notNull(),
17
+ updatedAt: text("updated_at").notNull()
18
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Drizzle relation graph for plans, sprints, and tasks.
5
+ */
6
+ import { relations } from "drizzle-orm";
7
+ import { plans } from "./plans";
8
+ import { sprints } from "./sprints";
9
+ import { taskTouchedFiles, tasks } from "./tasks";
10
+
11
+ export const plansRelations = relations(plans, ({ many }) => ({
12
+ sprints: many(sprints)
13
+ }));
14
+
15
+ export const sprintsRelations = relations(sprints, ({ one, many }) => ({
16
+ plan: one(plans, {
17
+ fields: [sprints.planId],
18
+ references: [plans.id]
19
+ }),
20
+ tasks: many(tasks)
21
+ }));
22
+
23
+ export const tasksRelations = relations(tasks, ({ one, many }) => ({
24
+ sprint: one(sprints, {
25
+ fields: [tasks.sprintId],
26
+ references: [sprints.id]
27
+ }),
28
+ touchedFiles: many(taskTouchedFiles)
29
+ }));
30
+
31
+ export const taskTouchedFilesRelations = relations(taskTouchedFiles, ({ one }) => ({
32
+ task: one(tasks, {
33
+ fields: [taskTouchedFiles.taskId],
34
+ references: [tasks.id]
35
+ })
36
+ }));
@@ -0,0 +1,33 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Drizzle schema for sprints scoped to a plan.
5
+ */
6
+ import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
7
+ import { plans } from "./plans";
8
+
9
+ export const sprints = sqliteTable(
10
+ "sprints",
11
+ {
12
+ id: text("id").primaryKey(),
13
+ slug: text("slug").notNull(),
14
+ planId: text("plan_id")
15
+ .notNull()
16
+ .references(() => plans.id, { onDelete: "cascade" }),
17
+ name: text("name").notNull(),
18
+ goal: text("goal").notNull(),
19
+ markdownContent: text("markdown_content"),
20
+ status: text("status").notNull(),
21
+ sprintOrder: integer("order").notNull().default(0),
22
+ startDate: text("start_date"),
23
+ endDate: text("end_date"),
24
+ createdAt: text("created_at").notNull(),
25
+ updatedAt: text("updated_at").notNull()
26
+ },
27
+ (table) => ({
28
+ planSlugUnique: uniqueIndex("sprints_plan_id_slug_unique").on(
29
+ table.planId,
30
+ table.slug
31
+ )
32
+ })
33
+ );