@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,137 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: SQLite-backed SprintRepository using Drizzle ORM.
5
+ */
6
+ import { and, asc, eq } from "drizzle-orm";
7
+ import { randomUUID } from "node:crypto";
8
+ import type {
9
+ CreateSprintInput,
10
+ Sprint,
11
+ UpdateSprintInput
12
+ } from "../../../domain/entities/sprint.entity";
13
+ import type { SprintRepository } from "../../../domain/repositories/sprint.repository";
14
+ import { StorageError } from "../../../errors/backend-errors.js";
15
+ import { sprints } from "../../../db/schema/sprints";
16
+ import type { DrizzleSqliteDb } from "./sqlite-db";
17
+ import { toSprintEntity } from "./row-mappers";
18
+
19
+ /**
20
+ * Persists sprints with Drizzle SQLite.
21
+ */
22
+ export class DrizzleSprintRepository implements SprintRepository {
23
+ public constructor(private readonly db: DrizzleSqliteDb) {}
24
+
25
+ /** @inheritdoc */
26
+ public async create(input: CreateSprintInput): Promise<Sprint> {
27
+ const id = randomUUID();
28
+ const now = new Date().toISOString();
29
+ let order: number;
30
+ if (input.order === undefined) {
31
+ const rows = this.db
32
+ .select()
33
+ .from(sprints)
34
+ .where(eq(sprints.planId, input.planId))
35
+ .all();
36
+ order =
37
+ rows.length === 0
38
+ ? 0
39
+ : Math.max(...rows.map((r) => r.sprintOrder)) + 1;
40
+ } else {
41
+ order = input.order;
42
+ }
43
+ this.db
44
+ .insert(sprints)
45
+ .values({
46
+ id,
47
+ slug: input.slug,
48
+ planId: input.planId,
49
+ name: input.name,
50
+ goal: input.goal,
51
+ markdownContent: input.markdownContent ?? null,
52
+ status: "planned",
53
+ sprintOrder: order,
54
+ startDate: input.startDate ?? null,
55
+ endDate: input.endDate ?? null,
56
+ createdAt: now,
57
+ updatedAt: now
58
+ })
59
+ .run();
60
+ const created = this.db.select().from(sprints).where(eq(sprints.id, id)).get();
61
+ if (!created) {
62
+ throw new StorageError("Failed to read sprint after insert");
63
+ }
64
+ return toSprintEntity(created);
65
+ }
66
+
67
+ /** @inheritdoc */
68
+ public async findById(id: string): Promise<Sprint | null> {
69
+ const row = this.db.select().from(sprints).where(eq(sprints.id, id)).get();
70
+ return row ? toSprintEntity(row) : null;
71
+ }
72
+
73
+ /** @inheritdoc */
74
+ public async findBySlug(
75
+ planId: string,
76
+ slug: string
77
+ ): Promise<Sprint | null> {
78
+ const row = this.db
79
+ .select()
80
+ .from(sprints)
81
+ .where(and(eq(sprints.planId, planId), eq(sprints.slug, slug)))
82
+ .get();
83
+ return row ? toSprintEntity(row) : null;
84
+ }
85
+
86
+ /** @inheritdoc */
87
+ public async listByPlanId(planId: string): Promise<Sprint[]> {
88
+ const rows = this.db
89
+ .select()
90
+ .from(sprints)
91
+ .where(eq(sprints.planId, planId))
92
+ .orderBy(asc(sprints.sprintOrder))
93
+ .all();
94
+ return rows.map(toSprintEntity);
95
+ }
96
+
97
+ /** @inheritdoc */
98
+ public async update(
99
+ id: string,
100
+ input: UpdateSprintInput
101
+ ): Promise<Sprint | null> {
102
+ const existing = await this.findById(id);
103
+ if (!existing) {
104
+ return null;
105
+ }
106
+ const now = new Date().toISOString();
107
+ this.db
108
+ .update(sprints)
109
+ .set({
110
+ name: input.name ?? existing.name,
111
+ goal: input.goal ?? existing.goal,
112
+ markdownContent:
113
+ input.markdownContent !== undefined
114
+ ? input.markdownContent
115
+ : existing.markdownContent,
116
+ status: input.status ?? existing.status,
117
+ sprintOrder: input.order ?? existing.order,
118
+ startDate:
119
+ input.startDate !== undefined ? input.startDate : existing.startDate,
120
+ endDate: input.endDate !== undefined ? input.endDate : existing.endDate,
121
+ updatedAt: now
122
+ })
123
+ .where(eq(sprints.id, id))
124
+ .run();
125
+ return this.findById(id);
126
+ }
127
+
128
+ /** @inheritdoc */
129
+ public async delete(id: string): Promise<boolean> {
130
+ const before = await this.findById(id);
131
+ if (!before) {
132
+ return false;
133
+ }
134
+ this.db.delete(sprints).where(eq(sprints.id, id)).run();
135
+ return (await this.findById(id)) === null;
136
+ }
137
+ }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: SQLite-backed TaskRepository using Drizzle ORM.
5
+ */
6
+ import { and, asc, eq, inArray } from "drizzle-orm";
7
+ import { randomUUID } from "node:crypto";
8
+ import type {
9
+ CreateTaskInput,
10
+ Task,
11
+ TaskStatus,
12
+ TaskTouchedFile,
13
+ TaskTouchedFileInput,
14
+ UpdateTaskInput
15
+ } from "../../../domain/entities/task.entity";
16
+ import type {
17
+ TaskFilter,
18
+ TaskRepository
19
+ } from "../../../domain/repositories/task.repository";
20
+ import { StorageError, ValidationError } from "../../../errors/backend-errors.js";
21
+ import { sprints } from "../../../db/schema/sprints";
22
+ import { taskDependencies, taskTouchedFiles, tasks } from "../../../db/schema/tasks";
23
+ import type { DrizzleSqliteDb } from "./sqlite-db";
24
+ import {
25
+ toTaskDependencyEntity,
26
+ toTaskEntity,
27
+ toTaskTouchedFileEntity
28
+ } from "./row-mappers";
29
+
30
+ const TOUCHED_FILE_TYPES = new Set<string>([
31
+ "test",
32
+ "implementation",
33
+ "doc",
34
+ "config",
35
+ "other"
36
+ ]);
37
+
38
+ function validateTouchedFileInputs(items: TaskTouchedFileInput[]): TaskTouchedFileInput[] {
39
+ const seenPaths = new Set<string>();
40
+ const out: TaskTouchedFileInput[] = [];
41
+ for (const raw of items) {
42
+ const path = raw.path.trim();
43
+ if (path.length === 0) {
44
+ throw new ValidationError("Touched file path cannot be empty");
45
+ }
46
+ const norm = path.replace(/\\/g, "/");
47
+ if (norm.split("/").some((s) => s === "..")) {
48
+ throw new ValidationError("Touched file path cannot contain '..'");
49
+ }
50
+ if (!TOUCHED_FILE_TYPES.has(raw.fileType)) {
51
+ throw new ValidationError(`Invalid touched file type: ${raw.fileType}`);
52
+ }
53
+ if (seenPaths.has(norm)) {
54
+ throw new ValidationError(`Duplicate touched path: ${path}`);
55
+ }
56
+ seenPaths.add(norm);
57
+ out.push({ path, fileType: raw.fileType });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Persists tasks and dependency edges with Drizzle SQLite.
64
+ */
65
+ export class DrizzleTaskRepository implements TaskRepository {
66
+ public constructor(private readonly db: DrizzleSqliteDb) {}
67
+
68
+ private replaceTouchedFiles(taskId: string, items: TaskTouchedFileInput[]): void {
69
+ this.db.transaction((tx) => {
70
+ tx
71
+ .delete(taskTouchedFiles)
72
+ .where(eq(taskTouchedFiles.taskId, taskId))
73
+ .run();
74
+ for (const item of items) {
75
+ tx
76
+ .insert(taskTouchedFiles)
77
+ .values({
78
+ id: randomUUID(),
79
+ taskId,
80
+ path: item.path,
81
+ fileType: item.fileType
82
+ })
83
+ .run();
84
+ }
85
+ });
86
+ }
87
+
88
+ private touchedFilesByTaskIds(
89
+ taskIds: string[]
90
+ ): Map<string, TaskTouchedFile[]> {
91
+ const map = new Map<string, TaskTouchedFile[]>();
92
+ for (const id of taskIds) {
93
+ map.set(id, []);
94
+ }
95
+ if (taskIds.length === 0) {
96
+ return map;
97
+ }
98
+ const rows = this.db
99
+ .select()
100
+ .from(taskTouchedFiles)
101
+ .where(inArray(taskTouchedFiles.taskId, taskIds))
102
+ .all();
103
+ for (const r of rows) {
104
+ const e = toTaskTouchedFileEntity(r);
105
+ const list = map.get(e.taskId);
106
+ if (list !== undefined) {
107
+ list.push(e);
108
+ }
109
+ }
110
+ for (const list of map.values()) {
111
+ list.sort((a, b) =>
112
+ a.path.localeCompare(b.path, undefined, { sensitivity: "base" })
113
+ );
114
+ }
115
+ return map;
116
+ }
117
+
118
+ private attachTouchedFiles(taskList: Task[]): Task[] {
119
+ if (taskList.length === 0) {
120
+ return taskList;
121
+ }
122
+ const byId = this.touchedFilesByTaskIds(taskList.map((t) => t.id));
123
+ return taskList.map((t) => ({
124
+ ...t,
125
+ touchedFiles: byId.get(t.id) ?? []
126
+ }));
127
+ }
128
+
129
+ /** @inheritdoc */
130
+ public async create(input: CreateTaskInput): Promise<Task> {
131
+ const id = randomUUID();
132
+ const now = new Date().toISOString();
133
+ let order: number;
134
+ if (input.order === undefined) {
135
+ const rows = this.db
136
+ .select()
137
+ .from(tasks)
138
+ .where(eq(tasks.sprintId, input.sprintId))
139
+ .all();
140
+ order =
141
+ rows.length === 0
142
+ ? 0
143
+ : Math.max(...rows.map((r) => r.taskOrder)) + 1;
144
+ } else {
145
+ order = input.order;
146
+ }
147
+ const clash = this.db
148
+ .select()
149
+ .from(tasks)
150
+ .where(
151
+ and(eq(tasks.sprintId, input.sprintId), eq(tasks.taskOrder, order))
152
+ )
153
+ .get();
154
+ if (clash) {
155
+ throw new ValidationError(`Duplicate task order in sprint: ${order}`);
156
+ }
157
+ this.db
158
+ .insert(tasks)
159
+ .values({
160
+ id,
161
+ sprintId: input.sprintId,
162
+ title: input.title,
163
+ description: input.description ?? null,
164
+ status: "todo",
165
+ priority: input.priority,
166
+ taskOrder: order,
167
+ assignee: input.assignee ?? null,
168
+ tags: input.tags ?? null,
169
+ createdAt: now,
170
+ updatedAt: now
171
+ })
172
+ .run();
173
+ const created = this.db.select().from(tasks).where(eq(tasks.id, id)).get();
174
+ if (!created) {
175
+ throw new StorageError("Failed to read task after insert");
176
+ }
177
+ if (input.dependsOnTaskIds?.length) {
178
+ await this.setDependencies(id, [...new Set(input.dependsOnTaskIds)]);
179
+ }
180
+ const finalRow = this.db.select().from(tasks).where(eq(tasks.id, id)).get();
181
+ if (!finalRow) {
182
+ throw new StorageError("Failed to read task after dependency write");
183
+ }
184
+ if (input.touchedFiles !== undefined) {
185
+ this.replaceTouchedFiles(id, validateTouchedFileInputs(input.touchedFiles));
186
+ }
187
+ const hydrated = this.attachTouchedFiles([toTaskEntity(finalRow)])[0];
188
+ if (!hydrated) {
189
+ throw new StorageError("Failed to hydrate task after create");
190
+ }
191
+ return hydrated;
192
+ }
193
+
194
+ /** @inheritdoc */
195
+ public async findById(id: string): Promise<Task | null> {
196
+ const row = this.db.select().from(tasks).where(eq(tasks.id, id)).get();
197
+ if (!row) {
198
+ return null;
199
+ }
200
+ return this.attachTouchedFiles([toTaskEntity(row)])[0] ?? null;
201
+ }
202
+
203
+ /** @inheritdoc */
204
+ public async listBySprintId(
205
+ sprintId: string,
206
+ filter?: TaskFilter
207
+ ): Promise<Task[]> {
208
+ const conditions = [eq(tasks.sprintId, sprintId)];
209
+ if (filter?.status) {
210
+ conditions.push(eq(tasks.status, filter.status));
211
+ }
212
+ if (filter?.priority) {
213
+ conditions.push(eq(tasks.priority, filter.priority));
214
+ }
215
+ if (filter?.assignee !== undefined) {
216
+ conditions.push(eq(tasks.assignee, filter.assignee));
217
+ }
218
+ const rows = this.db
219
+ .select()
220
+ .from(tasks)
221
+ .where(and(...conditions))
222
+ .orderBy(asc(tasks.taskOrder))
223
+ .all();
224
+ return this.attachTouchedFiles(rows.map(toTaskEntity));
225
+ }
226
+
227
+ /** @inheritdoc */
228
+ public async listByPlanId(
229
+ planId: string,
230
+ filter?: TaskFilter
231
+ ): Promise<Task[]> {
232
+ const conditions = [eq(sprints.planId, planId)];
233
+ if (filter?.status) {
234
+ conditions.push(eq(tasks.status, filter.status));
235
+ }
236
+ if (filter?.priority) {
237
+ conditions.push(eq(tasks.priority, filter.priority));
238
+ }
239
+ if (filter?.assignee !== undefined) {
240
+ conditions.push(eq(tasks.assignee, filter.assignee));
241
+ }
242
+ const rows = this.db
243
+ .select()
244
+ .from(tasks)
245
+ .innerJoin(sprints, eq(tasks.sprintId, sprints.id))
246
+ .where(and(...conditions))
247
+ .orderBy(asc(tasks.taskOrder))
248
+ .all();
249
+ return this.attachTouchedFiles(rows.map((row) => toTaskEntity(row.tasks)));
250
+ }
251
+
252
+ /** @inheritdoc */
253
+ public async update(
254
+ id: string,
255
+ input: UpdateTaskInput
256
+ ): Promise<Task | null> {
257
+ const existing = await this.findById(id);
258
+ if (!existing) {
259
+ return null;
260
+ }
261
+ const now = new Date().toISOString();
262
+ if (input.order !== undefined && input.order !== existing.order) {
263
+ const clash = this.db
264
+ .select()
265
+ .from(tasks)
266
+ .where(
267
+ and(
268
+ eq(tasks.sprintId, existing.sprintId),
269
+ eq(tasks.taskOrder, input.order)
270
+ )
271
+ )
272
+ .get();
273
+ if (clash && clash.id !== id) {
274
+ throw new ValidationError(
275
+ `Duplicate task order in sprint: ${input.order}`
276
+ );
277
+ }
278
+ }
279
+ this.db
280
+ .update(tasks)
281
+ .set({
282
+ title: input.title ?? existing.title,
283
+ description:
284
+ input.description !== undefined
285
+ ? input.description
286
+ : existing.description,
287
+ status: input.status ?? existing.status,
288
+ priority: input.priority ?? existing.priority,
289
+ taskOrder: input.order ?? existing.order,
290
+ assignee:
291
+ input.assignee !== undefined ? input.assignee : existing.assignee,
292
+ tags: input.tags !== undefined ? input.tags : existing.tags,
293
+ updatedAt: now
294
+ })
295
+ .where(eq(tasks.id, id))
296
+ .run();
297
+ if (input.touchedFiles !== undefined) {
298
+ this.replaceTouchedFiles(id, validateTouchedFileInputs(input.touchedFiles));
299
+ }
300
+ return this.findById(id);
301
+ }
302
+
303
+ /** @inheritdoc */
304
+ public async move(
305
+ taskId: string,
306
+ targetSprintId: string
307
+ ): Promise<Task | null> {
308
+ const existing = await this.findById(taskId);
309
+ if (!existing) {
310
+ return null;
311
+ }
312
+ this.db.transaction((tx) => {
313
+ const rows = tx
314
+ .select()
315
+ .from(tasks)
316
+ .where(eq(tasks.sprintId, targetSprintId))
317
+ .all();
318
+ const nextOrder =
319
+ rows.length === 0
320
+ ? 0
321
+ : Math.max(...rows.map((r) => r.taskOrder)) + 1;
322
+ const now = new Date().toISOString();
323
+ tx
324
+ .update(tasks)
325
+ .set({
326
+ sprintId: targetSprintId,
327
+ taskOrder: nextOrder,
328
+ updatedAt: now
329
+ })
330
+ .where(eq(tasks.id, taskId))
331
+ .run();
332
+ });
333
+ return this.findById(taskId);
334
+ }
335
+
336
+ /** @inheritdoc */
337
+ public async delete(id: string): Promise<boolean> {
338
+ const before = await this.findById(id);
339
+ if (!before) {
340
+ return false;
341
+ }
342
+ this.db.delete(tasks).where(eq(tasks.id, id)).run();
343
+ return (await this.findById(id)) === null;
344
+ }
345
+
346
+ /** @inheritdoc */
347
+ public async getDependencies(taskId: string) {
348
+ const rows = this.db
349
+ .select()
350
+ .from(taskDependencies)
351
+ .where(eq(taskDependencies.taskId, taskId))
352
+ .all();
353
+ return rows.map(toTaskDependencyEntity);
354
+ }
355
+
356
+ /** @inheritdoc */
357
+ public async listDependentTaskIds(dependsOnTaskId: string): Promise<string[]> {
358
+ const rows = this.db
359
+ .select()
360
+ .from(taskDependencies)
361
+ .where(eq(taskDependencies.dependsOnTaskId, dependsOnTaskId))
362
+ .all();
363
+ return rows.map((r) => r.taskId);
364
+ }
365
+
366
+ /** @inheritdoc */
367
+ public async setDependencies(
368
+ taskId: string,
369
+ dependsOnTaskIds: string[]
370
+ ): Promise<void> {
371
+ const unique = [...new Set(dependsOnTaskIds)];
372
+ this.db.transaction((tx) => {
373
+ tx
374
+ .delete(taskDependencies)
375
+ .where(eq(taskDependencies.taskId, taskId))
376
+ .run();
377
+ for (const dependsOnTaskId of unique) {
378
+ tx
379
+ .insert(taskDependencies)
380
+ .values({ taskId, dependsOnTaskId })
381
+ .run();
382
+ }
383
+ });
384
+ }
385
+
386
+ /** @inheritdoc */
387
+ public async countByStatusGlobally(): Promise<Record<TaskStatus, number>> {
388
+ const rows = this.db.select().from(tasks).all();
389
+ const out: Record<TaskStatus, number> = {
390
+ todo: 0,
391
+ in_progress: 0,
392
+ blocked: 0,
393
+ done: 0
394
+ };
395
+ for (const r of rows) {
396
+ const s = r.status as TaskStatus;
397
+ if (s in out) {
398
+ out[s] += 1;
399
+ }
400
+ }
401
+ return out;
402
+ }
403
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Drizzle SQLite repository adapter exports.
5
+ */
6
+
7
+ export { DrizzlePlanRepository } from "./drizzle-plan.repository";
8
+ export { DrizzleSprintRepository } from "./drizzle-sprint.repository";
9
+ export { DrizzleTaskRepository } from "./drizzle-task.repository";
10
+ export type { DrizzleSqliteDb } from "./sqlite-db";
11
+ export {
12
+ toPlanEntity,
13
+ toSprintEntity,
14
+ toTaskDependencyEntity,
15
+ toTaskEntity
16
+ } from "./row-mappers";
@@ -0,0 +1,106 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Maps Drizzle SQLite rows to domain entities (single ORM boundary).
5
+ */
6
+ import type { InferSelectModel } from "drizzle-orm";
7
+ import type { Plan } from "../../../domain/entities/plan.entity";
8
+ import type { Sprint } from "../../../domain/entities/sprint.entity";
9
+ import type {
10
+ Task,
11
+ TaskDependency,
12
+ TaskTouchedFile
13
+ } from "../../../domain/entities/task.entity";
14
+ import { plans } from "../../../db/schema/plans";
15
+ import { sprints } from "../../../db/schema/sprints";
16
+ import { taskDependencies, taskTouchedFiles, tasks } from "../../../db/schema/tasks";
17
+
18
+ type PlanRow = InferSelectModel<typeof plans>;
19
+ type SprintRow = InferSelectModel<typeof sprints>;
20
+ type TaskRow = InferSelectModel<typeof tasks>;
21
+ type TaskDependencyRow = InferSelectModel<typeof taskDependencies>;
22
+ type TaskTouchedFileRow = InferSelectModel<typeof taskTouchedFiles>;
23
+
24
+ /**
25
+ * @param row Persisted plan row.
26
+ * @returns Domain plan entity.
27
+ */
28
+ export function toPlanEntity(row: PlanRow): Plan {
29
+ return {
30
+ id: row.id,
31
+ slug: row.slug,
32
+ title: row.title,
33
+ description: row.description ?? null,
34
+ markdownContent: row.markdownContent ?? null,
35
+ status: row.status as Plan["status"],
36
+ isActive: row.isActive,
37
+ createdAt: row.createdAt,
38
+ updatedAt: row.updatedAt
39
+ };
40
+ }
41
+
42
+ /**
43
+ * @param row Persisted sprint row.
44
+ * @returns Domain sprint entity.
45
+ */
46
+ export function toSprintEntity(row: SprintRow): Sprint {
47
+ return {
48
+ id: row.id,
49
+ slug: row.slug,
50
+ planId: row.planId,
51
+ name: row.name,
52
+ goal: row.goal,
53
+ markdownContent: row.markdownContent ?? null,
54
+ status: row.status as Sprint["status"],
55
+ order: row.sprintOrder,
56
+ startDate: row.startDate ?? null,
57
+ endDate: row.endDate ?? null,
58
+ createdAt: row.createdAt,
59
+ updatedAt: row.updatedAt
60
+ };
61
+ }
62
+
63
+ /**
64
+ * @param row Persisted task row.
65
+ * @returns Domain task entity.
66
+ */
67
+ export function toTaskEntity(row: TaskRow): Task {
68
+ return {
69
+ id: row.id,
70
+ sprintId: row.sprintId,
71
+ title: row.title,
72
+ description: row.description ?? null,
73
+ status: row.status as Task["status"],
74
+ priority: row.priority as Task["priority"],
75
+ order: row.taskOrder,
76
+ assignee: row.assignee ?? null,
77
+ tags: row.tags ?? null,
78
+ touchedFiles: [],
79
+ createdAt: row.createdAt,
80
+ updatedAt: row.updatedAt
81
+ };
82
+ }
83
+
84
+ /**
85
+ * @param row Persisted task_touched_files row.
86
+ * @returns Domain touched-file entity.
87
+ */
88
+ export function toTaskTouchedFileEntity(row: TaskTouchedFileRow): TaskTouchedFile {
89
+ return {
90
+ id: row.id,
91
+ taskId: row.taskId,
92
+ path: row.path,
93
+ fileType: row.fileType as TaskTouchedFile["fileType"]
94
+ };
95
+ }
96
+
97
+ /**
98
+ * @param row Persisted task_dependencies row.
99
+ * @returns Domain dependency edge.
100
+ */
101
+ export function toTaskDependencyEntity(row: TaskDependencyRow): TaskDependency {
102
+ return {
103
+ taskId: row.taskId,
104
+ dependsOnTaskId: row.dependsOnTaskId
105
+ };
106
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * package: @sprintdock/backend
3
+ * author: vikash sharma
4
+ * description: Shared Drizzle SQLite client type for better-sqlite3 and sql.js runtimes.
5
+ */
6
+ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
7
+ import type { SQLJsDatabase } from "drizzle-orm/sql-js";
8
+ import type * as schema from "../../../db/schema/index";
9
+
10
+ /** Drizzle SQLite database used by repository adapters (file DB or in-memory tests). */
11
+ export type DrizzleSqliteDb =
12
+ | BetterSQLite3Database<typeof schema>
13
+ | SQLJsDatabase<typeof schema>;