@zinn-dev/core 0.0.4 → 0.1.0
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.
- package/index.ts +342 -20
- package/package.json +29 -25
- package/src/config.ts +10 -8
- package/src/constant.ts +3 -1
- package/src/db/column.ts +39 -0
- package/src/db/project.test.ts +183 -0
- package/src/db/project.ts +73 -0
- package/src/db/task.ts +152 -0
- package/src/db.ts +44 -19
- package/src/lib.ts +4 -0
- package/src/schemas/task.test.ts +31 -0
- package/src/schemas/task.ts +38 -0
- package/src/types.ts +37 -0
package/index.ts
CHANGED
|
@@ -1,31 +1,353 @@
|
|
|
1
|
-
|
|
2
|
-
import "./src/config";
|
|
3
|
-
import { db, addProject, getProjectByKey, deleteProjectByKey } from "./src/db";
|
|
1
|
+
import type { Column } from "./src/types";
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
import { randomUUIDv7 } from "bun";
|
|
4
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
5
|
+
|
|
6
|
+
import * as dbProject from "./src/db/project";
|
|
7
|
+
import * as dbColumn from "./src/db/column";
|
|
8
|
+
import * as dbTask from "./src/db/task";
|
|
9
|
+
import { standardizeProjectKey } from "./src/lib";
|
|
10
|
+
import { validateTaskInput, taskCreateSchema, taskEditSchema } from "./src/schemas/task";
|
|
11
|
+
import type { TaskCreateInput, TaskEditInput } from "./src/schemas/task";
|
|
12
|
+
|
|
13
|
+
export { taskCreateSchema, taskEditSchema } from "./src/schemas/task";
|
|
14
|
+
export type { TaskCreateInput, TaskEditInput } from "./src/schemas/task";
|
|
15
|
+
|
|
16
|
+
export const project = {
|
|
17
|
+
getById: dbProject.getById,
|
|
18
|
+
getByKey: dbProject.getByKey,
|
|
19
|
+
create: (props: { key: string; name: string }) => {
|
|
20
|
+
const validKeyRegex = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
21
|
+
if (!validKeyRegex.test(props.key)) {
|
|
22
|
+
throw new Error("Project key must start with a letter and contain only letters and numbers");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const invalidNameCharRegex = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
26
|
+
// control characters and Unicode line separators are not valid project data.
|
|
27
|
+
if (props.name.trim().length === 0 || invalidNameCharRegex.test(props.name)) {
|
|
28
|
+
throw new Error("Project name must contain printable text on a single line");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const existingProject = dbProject.getByKey(props.key);
|
|
32
|
+
|
|
33
|
+
if (existingProject != null) {
|
|
34
|
+
throw new Error(`Project with key "${existingProject.key}" already exists!`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const project = dbProject.create(props)!;
|
|
38
|
+
|
|
39
|
+
const defaultColumns = ["Backlog", "TODO", "In Progress", "Review", "Done"];
|
|
40
|
+
let lastColumnOrder: string | null = null;
|
|
41
|
+
|
|
42
|
+
for (const col of defaultColumns) {
|
|
43
|
+
const columnOrder = generateKeyBetween(lastColumnOrder, null);
|
|
44
|
+
dbColumn.create({
|
|
45
|
+
id: randomUUIDv7(),
|
|
46
|
+
project_id: project?.id,
|
|
47
|
+
name: col,
|
|
48
|
+
column_order: columnOrder,
|
|
49
|
+
})!;
|
|
50
|
+
lastColumnOrder = columnOrder;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
getAll: dbProject.getAll,
|
|
54
|
+
delete: (key: string) => {
|
|
55
|
+
const project = dbProject.getByKey(key);
|
|
56
|
+
|
|
57
|
+
if (project == null) {
|
|
58
|
+
throw new Error(`Project with key "${key}" does not exist!`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
dbProject.deleteByKey(key);
|
|
62
|
+
},
|
|
63
|
+
standardizeKey: standardizeProjectKey,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const column = {
|
|
67
|
+
getById: dbColumn.getById,
|
|
68
|
+
getAllByProjectKey: (key: string) => {
|
|
69
|
+
const standardizedKey = standardizeProjectKey(key);
|
|
70
|
+
const project = dbProject.getByKey(standardizedKey);
|
|
71
|
+
|
|
72
|
+
if (project == null) {
|
|
73
|
+
throw new Error(`Project with key "${key}" does not exist!`);
|
|
74
|
+
}
|
|
75
|
+
return dbColumn.getAllByProjectId(project.id);
|
|
76
|
+
},
|
|
77
|
+
create: (column: Omit<Column, "id" | "project_id" | "column_order"> & { projectKey: string }) => {
|
|
78
|
+
const standardizedKey = standardizeProjectKey(column.projectKey);
|
|
79
|
+
const project = dbProject.getByKey(standardizedKey);
|
|
9
80
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const project = getProjectByKey(standardizedKey);
|
|
81
|
+
if (project == null) {
|
|
82
|
+
throw new Error(`Project with key "${standardizedKey}" does not exist!`);
|
|
83
|
+
}
|
|
14
84
|
|
|
15
|
-
|
|
16
|
-
|
|
85
|
+
const allProjectColumns = dbColumn.getAllByProjectId(project.id);
|
|
86
|
+
const existingColumn = allProjectColumns.find(
|
|
87
|
+
(existing) => existing.name.toLowerCase() === column.name.toLowerCase(),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (existingColumn != null) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Column with name "${column.name}" already exists in project "${project.key}"!`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lastColOrder = allProjectColumns[allProjectColumns.length - 1]?.column_order ?? null;
|
|
97
|
+
const res = dbColumn.create({
|
|
98
|
+
id: randomUUIDv7(),
|
|
99
|
+
name: column.name,
|
|
100
|
+
column_order: generateKeyBetween(lastColOrder, null),
|
|
101
|
+
project_id: project.id,
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function getTaskByKey(taskKey: string) {
|
|
107
|
+
const taskKeyMatch = /^([A-Za-z][A-Za-z0-9]*)-(\d+)$/.exec(taskKey);
|
|
108
|
+
if (taskKeyMatch == null) {
|
|
109
|
+
throw new Error(`Invalid task key "${taskKey}". Expected format PROJECT-1`);
|
|
17
110
|
}
|
|
18
111
|
|
|
19
|
-
|
|
20
|
-
|
|
112
|
+
const projectKey = taskKeyMatch[1]!;
|
|
113
|
+
const taskNumber = Number(taskKeyMatch[2]);
|
|
114
|
+
if (!Number.isSafeInteger(taskNumber) || taskNumber < 1) {
|
|
115
|
+
throw new Error(`Invalid task key "${taskKey}". Expected format PROJECT-1`);
|
|
116
|
+
}
|
|
21
117
|
|
|
22
|
-
|
|
23
|
-
const standardizedKey = standardizeKey(key);
|
|
24
|
-
const project = getProjectByKey(standardizedKey);
|
|
118
|
+
const project = dbProject.getByKey(projectKey);
|
|
25
119
|
|
|
26
120
|
if (project == null) {
|
|
27
|
-
throw new Error(`Project with key "${
|
|
121
|
+
throw new Error(`Project with key "${standardizeProjectKey(projectKey)}" does not exist!`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const taskMatch = dbTask.getByProjectIdAndNumber({
|
|
125
|
+
projectId: project.id,
|
|
126
|
+
number: taskNumber,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (taskMatch == null) {
|
|
130
|
+
throw new Error(`Task "${project.key}-${taskNumber}" does not exist!`);
|
|
28
131
|
}
|
|
29
132
|
|
|
30
|
-
|
|
133
|
+
return taskMatch;
|
|
31
134
|
}
|
|
135
|
+
|
|
136
|
+
type TaskOrderProps =
|
|
137
|
+
| {
|
|
138
|
+
taskKey: string;
|
|
139
|
+
direction: "top" | "up" | "down" | "bottom";
|
|
140
|
+
targetTaskKey?: never;
|
|
141
|
+
}
|
|
142
|
+
| {
|
|
143
|
+
taskKey: string;
|
|
144
|
+
direction: "before" | "after";
|
|
145
|
+
targetTaskKey: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const task = {
|
|
149
|
+
create: (input: TaskCreateInput) => {
|
|
150
|
+
const task = validateTaskInput(taskCreateSchema, input);
|
|
151
|
+
|
|
152
|
+
// TODO: if anything after this fails, especially the task creation, the counter is still incremented but not assigned to any task
|
|
153
|
+
// TODO: rename
|
|
154
|
+
const project = dbProject.incrementTaskCounterById(task.project_id);
|
|
155
|
+
|
|
156
|
+
if (project == null) {
|
|
157
|
+
throw new Error(`Project's next task number could not be retrieved (ID:${task.project_id})`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const initialColumn = dbColumn.getAllByProjectId(task.project_id)[0]!;
|
|
161
|
+
const lastTask = dbTask.getLastByColumnId(initialColumn.id);
|
|
162
|
+
const order = generateKeyBetween(lastTask?.task_order ?? null, null);
|
|
163
|
+
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
return dbTask.create({
|
|
166
|
+
id: randomUUIDv7(),
|
|
167
|
+
project_id: task.project_id,
|
|
168
|
+
column_id: initialColumn.id,
|
|
169
|
+
number: project.task_count,
|
|
170
|
+
title: task.title,
|
|
171
|
+
description: task.description,
|
|
172
|
+
task_order: order,
|
|
173
|
+
created_at: now,
|
|
174
|
+
updated_at: now,
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
getAll: (props: { projectKey?: string; archive?: dbTask.TaskArchiveFilter } = {}) => {
|
|
178
|
+
if (props.projectKey == null) {
|
|
179
|
+
return dbTask.getAll(props.archive);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const taskProject = dbProject.getByKey(props.projectKey);
|
|
183
|
+
|
|
184
|
+
if (taskProject == null) {
|
|
185
|
+
throw new Error(`Project with key "${props.projectKey}" does not exist!`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return dbTask.getAllByProjectId(taskProject.id, props.archive);
|
|
189
|
+
},
|
|
190
|
+
getByKey: getTaskByKey,
|
|
191
|
+
/** Edit supplied content fields, including on archived tasks. Unchanged edits are no-ops. */
|
|
192
|
+
edit: (input: TaskEditInput) => {
|
|
193
|
+
const props = validateTaskInput(taskEditSchema, input);
|
|
194
|
+
const existing = getTaskByKey(props.taskKey);
|
|
195
|
+
const isTitleUnchanged = props.title === undefined || props.title === existing.title;
|
|
196
|
+
const isDescriptionUnchanged =
|
|
197
|
+
props.description === undefined || props.description === existing.description;
|
|
198
|
+
|
|
199
|
+
if (isTitleUnchanged && isDescriptionUnchanged) {
|
|
200
|
+
return existing;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return dbTask.update({
|
|
204
|
+
id: existing.id,
|
|
205
|
+
title: props.title,
|
|
206
|
+
description: props.description,
|
|
207
|
+
updated_at: Date.now(),
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
/**
|
|
211
|
+
* Moves a task to another column in its project.
|
|
212
|
+
*
|
|
213
|
+
* Moving a task to a different column lists it last in that column,
|
|
214
|
+
* matching placement at the bottom of a visual kanban column.
|
|
215
|
+
* Giving a task's current column as the target will not do anything.
|
|
216
|
+
* Archived tasks must be unarchived before they can be moved.
|
|
217
|
+
*/
|
|
218
|
+
move: (props: { taskKey: string; targetColumn: string }) => {
|
|
219
|
+
const taskMatch = getTaskByKey(props.taskKey);
|
|
220
|
+
|
|
221
|
+
if (taskMatch.archived_at != null) {
|
|
222
|
+
throw new Error(`Archived task "${props.taskKey}" cannot be moved`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const columnMatch = dbColumn
|
|
226
|
+
.getAllByProjectId(taskMatch.project_id)
|
|
227
|
+
.find((column) => column.name.toLowerCase() === props.targetColumn.toLowerCase());
|
|
228
|
+
|
|
229
|
+
if (columnMatch == null) {
|
|
230
|
+
// TODO: maybe a `did you mean` type of fuzzy check for misspelllings
|
|
231
|
+
throw new Error(
|
|
232
|
+
`Column "${props.targetColumn}" does not exist in task "${props.taskKey}"'s project!`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (columnMatch.id === taskMatch.column_id) {
|
|
237
|
+
return taskMatch;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const lastTask = dbTask.getLastByColumnId(columnMatch.id);
|
|
241
|
+
const order = generateKeyBetween(lastTask?.task_order ?? null, null);
|
|
242
|
+
|
|
243
|
+
return dbTask.update({
|
|
244
|
+
id: taskMatch.id,
|
|
245
|
+
column_id: columnMatch.id,
|
|
246
|
+
task_order: order,
|
|
247
|
+
updated_at: Date.now(),
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
order: (props: TaskOrderProps) => {
|
|
251
|
+
const { taskKey, direction } = props;
|
|
252
|
+
const taskMatch = getTaskByKey(taskKey);
|
|
253
|
+
|
|
254
|
+
if (taskMatch.archived_at != null) {
|
|
255
|
+
throw new Error(`Archived task "${taskKey}" cannot be reordered`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const allTasks = dbTask.getAllByColumnId(taskMatch.column_id);
|
|
259
|
+
const taskIndex = allTasks.findIndex((candidate) => candidate.id === taskMatch.id);
|
|
260
|
+
const otherTasks = allTasks.filter((candidate) => candidate.id !== taskMatch.id);
|
|
261
|
+
let insertionIndex: number;
|
|
262
|
+
|
|
263
|
+
switch (direction) {
|
|
264
|
+
case "top":
|
|
265
|
+
insertionIndex = 0;
|
|
266
|
+
break;
|
|
267
|
+
case "up":
|
|
268
|
+
insertionIndex = Math.max(0, taskIndex - 1);
|
|
269
|
+
break;
|
|
270
|
+
case "down":
|
|
271
|
+
insertionIndex = Math.min(otherTasks.length, taskIndex + 1);
|
|
272
|
+
break;
|
|
273
|
+
case "bottom":
|
|
274
|
+
insertionIndex = otherTasks.length;
|
|
275
|
+
break;
|
|
276
|
+
case "before":
|
|
277
|
+
case "after": {
|
|
278
|
+
const targetTask = getTaskByKey(props.targetTaskKey);
|
|
279
|
+
|
|
280
|
+
if (targetTask.id === taskMatch.id) {
|
|
281
|
+
return taskMatch;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (targetTask.project_id !== taskMatch.project_id) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Tasks "${taskKey}" and "${props.targetTaskKey}" must be in the same project`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (targetTask.column_id !== taskMatch.column_id) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Tasks "${taskKey}" and "${props.targetTaskKey}" must be in the same column`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (targetTask.archived_at != null) {
|
|
297
|
+
throw new Error(`Archived task "${props.targetTaskKey}" cannot be an ordering target`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const targetIndex = otherTasks.findIndex((candidate) => candidate.id === targetTask.id);
|
|
301
|
+
insertionIndex = direction === "before" ? targetIndex : targetIndex + 1;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (insertionIndex === taskIndex) {
|
|
307
|
+
return taskMatch;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const futurePrevTask = otherTasks[insertionIndex - 1] ?? null;
|
|
311
|
+
const futureNextTask = otherTasks[insertionIndex] ?? null;
|
|
312
|
+
|
|
313
|
+
return dbTask.update({
|
|
314
|
+
id: taskMatch.id,
|
|
315
|
+
task_order: generateKeyBetween(
|
|
316
|
+
futurePrevTask?.task_order ?? null,
|
|
317
|
+
futureNextTask?.task_order ?? null,
|
|
318
|
+
),
|
|
319
|
+
updated_at: Date.now(),
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
delete: (taskKey: string) => {
|
|
323
|
+
const taskMatch = getTaskByKey(taskKey);
|
|
324
|
+
return dbTask.deleteById(taskMatch.id);
|
|
325
|
+
},
|
|
326
|
+
archive: (taskKey: string) => {
|
|
327
|
+
const taskMatch = getTaskByKey(taskKey);
|
|
328
|
+
|
|
329
|
+
if (taskMatch.archived_at != null) {
|
|
330
|
+
return taskMatch;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
return dbTask.update({ id: taskMatch.id, updated_at: now, archived_at: now });
|
|
335
|
+
},
|
|
336
|
+
unarchive: (taskKey: string) => {
|
|
337
|
+
const taskMatch = getTaskByKey(taskKey);
|
|
338
|
+
|
|
339
|
+
if (taskMatch.archived_at == null) {
|
|
340
|
+
return taskMatch;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const lastTask = dbTask.getLastByColumnId(taskMatch.column_id);
|
|
344
|
+
const order = generateKeyBetween(lastTask?.task_order ?? null, null);
|
|
345
|
+
|
|
346
|
+
return dbTask.update({
|
|
347
|
+
id: taskMatch.id,
|
|
348
|
+
task_order: order,
|
|
349
|
+
updated_at: Date.now(),
|
|
350
|
+
archived_at: null,
|
|
351
|
+
});
|
|
352
|
+
},
|
|
353
|
+
};
|
package/package.json
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
2
|
+
"name": "@zinn-dev/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared logic for zinn, a kanban workflow in the terminal.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/yethranayeh/zinn.git",
|
|
8
|
+
"directory": "packages/core"
|
|
9
|
+
},
|
|
10
|
+
"private": false,
|
|
11
|
+
"type": "module",
|
|
12
|
+
"module": "index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./index.ts",
|
|
16
|
+
"import": "./index.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"index.ts",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"fractional-indexing": "4.0.0",
|
|
28
|
+
"zod": "4.6.4"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { DATA_DIR, MAIN_DIR, CONFIG_PATH, defaultConfig } from "./constant";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export function ensureConfigSetup() {
|
|
5
|
+
if (!existsSync(MAIN_DIR)) {
|
|
6
|
+
mkdirSync(MAIN_DIR);
|
|
7
|
+
}
|
|
7
8
|
|
|
8
|
-
if (!existsSync(DATA_DIR)) {
|
|
9
|
-
|
|
10
|
-
}
|
|
9
|
+
if (!existsSync(DATA_DIR)) {
|
|
10
|
+
mkdirSync(DATA_DIR);
|
|
11
|
+
}
|
|
11
12
|
|
|
12
|
-
if (!existsSync(CONFIG_PATH)) {
|
|
13
|
-
|
|
13
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
14
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(defaultConfig) + "\n", { encoding: "utf-8" });
|
|
15
|
+
}
|
|
14
16
|
}
|
package/src/constant.ts
CHANGED
|
@@ -5,13 +5,15 @@ export const MAIN_FOLDER_NAME = ".zinn";
|
|
|
5
5
|
export const CONFIG_FILE_NAME = "settings.json";
|
|
6
6
|
export const DB_FILE_NAME = "zinn.sqlite";
|
|
7
7
|
|
|
8
|
-
export const MAIN_DIR = join(homedir(), MAIN_FOLDER_NAME);
|
|
8
|
+
export const MAIN_DIR = process.env.ZINN_DIR ?? join(homedir(), MAIN_FOLDER_NAME);
|
|
9
9
|
export const CONFIG_PATH = join(MAIN_DIR, CONFIG_FILE_NAME);
|
|
10
10
|
export const DATA_DIR = join(MAIN_DIR, "data");
|
|
11
11
|
// TODO: environment variable based path overriding for tests
|
|
12
12
|
export const DB_PATH = join(DATA_DIR, DB_FILE_NAME);
|
|
13
13
|
export const DB_TABLE = {
|
|
14
14
|
project: "project",
|
|
15
|
+
projectColumn: "project_column",
|
|
16
|
+
task: "task",
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
// TODO: implement zod schema for setting.json
|
package/src/db/column.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Bind, Column } from "../types";
|
|
2
|
+
|
|
3
|
+
import { DB_TABLE } from "../constant";
|
|
4
|
+
import { getDb } from "../db";
|
|
5
|
+
|
|
6
|
+
export function getById(id: string) {
|
|
7
|
+
const db = getDb();
|
|
8
|
+
return db
|
|
9
|
+
.query<Column, Partial<Bind<Pick<Column, "id">>>>(`SELECT * FROM ${DB_TABLE.projectColumn}
|
|
10
|
+
WHERE id = $id`)
|
|
11
|
+
.get({ $id: id });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getAllByProjectId(projectId: string) {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
return db
|
|
17
|
+
.query<
|
|
18
|
+
Column,
|
|
19
|
+
Partial<Bind<Pick<Column, "project_id">>>
|
|
20
|
+
>(`SELECT * FROM ${DB_TABLE.projectColumn}
|
|
21
|
+
WHERE project_id = $project_id
|
|
22
|
+
ORDER BY column_order`)
|
|
23
|
+
.all({ $project_id: projectId });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function create(column: Column) {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
const query = db.query<Column, Bind<Column>>(`INSERT INTO
|
|
29
|
+
${DB_TABLE.projectColumn} (id, project_id, name, column_order)
|
|
30
|
+
VALUES ($id, $project_id, $name, $column_order)
|
|
31
|
+
RETURNING *;`);
|
|
32
|
+
|
|
33
|
+
return query.get({
|
|
34
|
+
$id: column.id,
|
|
35
|
+
$project_id: column.project_id,
|
|
36
|
+
$name: column.name,
|
|
37
|
+
$column_order: column.column_order,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { Task } from "../types";
|
|
7
|
+
|
|
8
|
+
const TEMP_DIR_ROOT = tmpdir();
|
|
9
|
+
const PREFIX = join(TEMP_DIR_ROOT, "zinntest-");
|
|
10
|
+
const TEST_DIR = mkdtempSync(PREFIX);
|
|
11
|
+
process.env.ZINN_DIR = TEST_DIR;
|
|
12
|
+
|
|
13
|
+
// ? Dynamic so env is set before `MAIN_DIR` constant is initialized.
|
|
14
|
+
const project = await import("./project");
|
|
15
|
+
const column = await import("./column");
|
|
16
|
+
const task = await import("./task");
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
if (!TEST_DIR.startsWith(PREFIX)) {
|
|
20
|
+
throw new Error(`Refusing to delete "${TEST_DIR}": not a ${PREFIX}* directory`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
rmSync(TEST_DIR, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("project.create can establish a project", () => {
|
|
27
|
+
const created = project.create({ key: "WIP", name: "Work In Progress" });
|
|
28
|
+
|
|
29
|
+
expect(created).not.toBeNull();
|
|
30
|
+
expect(created?.key).toBe("WIP");
|
|
31
|
+
expect(created?.name).toBe("Work In Progress");
|
|
32
|
+
expect(created?.task_count).toBe(0);
|
|
33
|
+
expect(created?.archived_at).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("project.create standardizes the key", () => {
|
|
37
|
+
const created = project.create({ key: "key", name: "Lowercased Key" });
|
|
38
|
+
|
|
39
|
+
expect(created?.key).toBe("KEY");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("project.create rejects a duplicate key", () => {
|
|
43
|
+
expect(project.create({ key: "DUPE", name: "First" })?.key).toBe("DUPE");
|
|
44
|
+
|
|
45
|
+
// ? "dupe" standardizes to the already-taken "DUPE".
|
|
46
|
+
expect(() => project.create({ key: "dupe", name: "Second" })).toThrow(
|
|
47
|
+
"UNIQUE constraint failed: project.key",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("project.getById finds an existing project", () => {
|
|
52
|
+
const created = project.create({ key: "BYID", name: "By Id" })!;
|
|
53
|
+
const found = project.getById(created.id);
|
|
54
|
+
|
|
55
|
+
expect(found?.id).toEqual(created.id);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("project.getById returns null for an unknown id", () => {
|
|
59
|
+
expect(project.getById("unknwon")).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("project.getByKey is case insensitive for matching", () => {
|
|
63
|
+
const created = project.create({ key: "someKey", name: "Got By Key" })!;
|
|
64
|
+
|
|
65
|
+
expect(project.getByKey("SOMEKEY")).toEqual(created);
|
|
66
|
+
expect(project.getByKey("somekey")).toEqual(created);
|
|
67
|
+
expect(project.getByKey("SomEkeY")).toEqual(created);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("project.getByKey returns null for an unknown key", () => {
|
|
71
|
+
expect(project.getByKey("NO")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("project.incrementTaskCounterById returns the next task number", () => {
|
|
75
|
+
const created = project.create({ key: "COUNT", name: "Counter" })!;
|
|
76
|
+
|
|
77
|
+
expect(project.incrementTaskCounterById(created.id)?.task_count).toBe(1);
|
|
78
|
+
expect(project.incrementTaskCounterById(created.id)?.task_count).toBe(2);
|
|
79
|
+
expect(project.getById(created.id)?.task_count).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("project.incrementTaskCounterById returns null for an unknown id", () => {
|
|
83
|
+
expect(project.incrementTaskCounterById("nonexistent")).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("project.deleteByKey removes a project regardless of key casing", () => {
|
|
87
|
+
const created = project.create({ key: "GONE", name: "Gone" })!;
|
|
88
|
+
|
|
89
|
+
const result = project.deleteByKey("gone");
|
|
90
|
+
|
|
91
|
+
expect(result.changes).toBe(1);
|
|
92
|
+
expect(project.getByKey("GONe")).toBeNull();
|
|
93
|
+
expect(project.getById(created.id)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("project.deleteByKey doesn't do anything for nonexistent project", () => {
|
|
97
|
+
expect(project.deleteByKey("void").changes).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function createTask(title: string) {
|
|
101
|
+
const suffix = crypto.randomUUID();
|
|
102
|
+
const taskProject = project.create({ key: `P${suffix.replaceAll("-", "")}`, name: title })!;
|
|
103
|
+
const taskColumn = {
|
|
104
|
+
id: crypto.randomUUID(),
|
|
105
|
+
project_id: taskProject.id,
|
|
106
|
+
name: "Backlog",
|
|
107
|
+
column_order: "a0",
|
|
108
|
+
};
|
|
109
|
+
column.create(taskColumn);
|
|
110
|
+
|
|
111
|
+
const created: Omit<Task, "archived_at"> = {
|
|
112
|
+
id: crypto.randomUUID(),
|
|
113
|
+
project_id: taskProject.id,
|
|
114
|
+
column_id: taskColumn.id,
|
|
115
|
+
number: 1,
|
|
116
|
+
title,
|
|
117
|
+
description: "original description",
|
|
118
|
+
task_order: "a0",
|
|
119
|
+
created_at: 10,
|
|
120
|
+
updated_at: 10,
|
|
121
|
+
};
|
|
122
|
+
task.create(created);
|
|
123
|
+
|
|
124
|
+
return created;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("task.update changes supplied fields and preserves omitted fields", () => {
|
|
128
|
+
const created = createTask("original title");
|
|
129
|
+
|
|
130
|
+
const updated = task.update({
|
|
131
|
+
id: created.id,
|
|
132
|
+
title: "updated title",
|
|
133
|
+
description: null,
|
|
134
|
+
updated_at: 20,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(updated).toEqual({
|
|
138
|
+
...created,
|
|
139
|
+
title: "updated title",
|
|
140
|
+
description: null,
|
|
141
|
+
updated_at: 20,
|
|
142
|
+
archived_at: null,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("task.update distinguishes null from an omitted field", () => {
|
|
147
|
+
const created = createTask("archivable task");
|
|
148
|
+
|
|
149
|
+
const archived = task.update({ id: created.id, archived_at: 30, updated_at: 30 })!;
|
|
150
|
+
const unarchived = task.update({ id: created.id, archived_at: null, updated_at: 40 });
|
|
151
|
+
|
|
152
|
+
expect(archived.archived_at).toBe(30);
|
|
153
|
+
expect(archived.description).toBe("original description");
|
|
154
|
+
expect(unarchived?.archived_at).toBeNull();
|
|
155
|
+
expect(unarchived?.description).toBe("original description");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("task lists exclude archived tasks by default and support explicit filters", () => {
|
|
159
|
+
const created = createTask("filtered task");
|
|
160
|
+
task.update({ id: created.id, archived_at: 50, updated_at: 50 });
|
|
161
|
+
|
|
162
|
+
expect(task.getAll().map(({ id }) => id)).not.toContain(created.id);
|
|
163
|
+
expect(task.getAllByProjectId(created.project_id)).toEqual([]);
|
|
164
|
+
expect(task.getAll("archived").map(({ id }) => id)).toContain(created.id);
|
|
165
|
+
expect(task.getAllByProjectId(created.project_id, "all").map(({ id }) => id)).toEqual([
|
|
166
|
+
created.id,
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("task.getLastByColumnId ignores archived tasks", () => {
|
|
171
|
+
const first = createTask("active ordering tail");
|
|
172
|
+
const archived = {
|
|
173
|
+
...first,
|
|
174
|
+
id: crypto.randomUUID(),
|
|
175
|
+
number: 2,
|
|
176
|
+
title: "archived ordering tail",
|
|
177
|
+
task_order: "z0",
|
|
178
|
+
};
|
|
179
|
+
task.create(archived);
|
|
180
|
+
task.update({ id: archived.id, archived_at: 50, updated_at: 50 });
|
|
181
|
+
|
|
182
|
+
expect(task.getLastByColumnId(first.column_id)?.id).toBe(first.id);
|
|
183
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Bind, Project } from "../types";
|
|
2
|
+
|
|
3
|
+
import { randomUUIDv7 } from "bun";
|
|
4
|
+
|
|
5
|
+
import { DB_TABLE } from "../constant";
|
|
6
|
+
import { getDb } from "../db";
|
|
7
|
+
import { standardizeProjectKey } from "../lib";
|
|
8
|
+
|
|
9
|
+
export function getAll() {
|
|
10
|
+
const db = getDb();
|
|
11
|
+
return db.query<Project, any>(`SELECT * FROM ${DB_TABLE.project}`).all();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getById(id: string) {
|
|
15
|
+
const db = getDb();
|
|
16
|
+
|
|
17
|
+
return db
|
|
18
|
+
.query<Project, Bind<{ id: string }>>(`SELECT * FROM ${DB_TABLE.project} WHERE id = $id`)
|
|
19
|
+
.get({ $id: id });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getByKey(key: string) {
|
|
23
|
+
const db = getDb();
|
|
24
|
+
const standardizedKey = standardizeProjectKey(key);
|
|
25
|
+
|
|
26
|
+
return db
|
|
27
|
+
.query<Project, Bind<{ key: string }>>(`SELECT * FROM ${DB_TABLE.project} WHERE key = $key`)
|
|
28
|
+
.get({ $key: standardizedKey });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function create({ key, name }: { key: string; name: string }) {
|
|
32
|
+
const db = getDb();
|
|
33
|
+
|
|
34
|
+
const query = db.query<
|
|
35
|
+
Project,
|
|
36
|
+
Bind<Pick<Project, "id" | "key" | "name" | "created_at" | "updated_at">>
|
|
37
|
+
>(`INSERT INTO
|
|
38
|
+
${DB_TABLE.project} (id, key, name, created_at, updated_at)
|
|
39
|
+
VALUES ($id, $key, $name, $created_at, $updated_at)
|
|
40
|
+
RETURNING *;`);
|
|
41
|
+
|
|
42
|
+
const time = Date.now();
|
|
43
|
+
const standardizedKey = standardizeProjectKey(key);
|
|
44
|
+
|
|
45
|
+
return query.get({
|
|
46
|
+
$id: randomUUIDv7(),
|
|
47
|
+
$key: standardizedKey,
|
|
48
|
+
$name: name,
|
|
49
|
+
$created_at: time,
|
|
50
|
+
$updated_at: time,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function incrementTaskCounterById(projectId: string) {
|
|
55
|
+
const db = getDb();
|
|
56
|
+
|
|
57
|
+
const nextTaskNumber = db
|
|
58
|
+
.query<Pick<Project, "task_count">, Bind<Pick<Project, "id">>>(`UPDATE ${DB_TABLE.project}
|
|
59
|
+
SET task_count = task_count + 1
|
|
60
|
+
WHERE id = $id RETURNING task_count`)
|
|
61
|
+
.get({ $id: projectId });
|
|
62
|
+
return nextTaskNumber;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// TODO: maybe allow both by `key` and `id`, and use whichever is provided
|
|
66
|
+
export function deleteByKey(key: string) {
|
|
67
|
+
const db = getDb();
|
|
68
|
+
|
|
69
|
+
const standardizedKey = standardizeProjectKey(key);
|
|
70
|
+
return db
|
|
71
|
+
.query(`DELETE FROM ${DB_TABLE.project} WHERE key = $key;`)
|
|
72
|
+
.run({ $key: standardizedKey });
|
|
73
|
+
}
|
package/src/db/task.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Bind, Task } from "../types";
|
|
2
|
+
|
|
3
|
+
import { getDb } from "../db";
|
|
4
|
+
import { DB_TABLE } from "../constant";
|
|
5
|
+
|
|
6
|
+
export type TaskArchiveFilter = "active" | "archived" | "all";
|
|
7
|
+
|
|
8
|
+
function getArchiveCondition(filter: TaskArchiveFilter) {
|
|
9
|
+
switch (filter) {
|
|
10
|
+
case "active":
|
|
11
|
+
return `${DB_TABLE.task}.archived_at IS NULL`;
|
|
12
|
+
case "archived":
|
|
13
|
+
return `${DB_TABLE.task}.archived_at IS NOT NULL`;
|
|
14
|
+
case "all":
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// TODO: refined parameters, preferably of type SQLBindings
|
|
20
|
+
export function getAll(archive: TaskArchiveFilter = "active") {
|
|
21
|
+
const db = getDb();
|
|
22
|
+
const archiveCondition = getArchiveCondition(archive);
|
|
23
|
+
const whereClause = archiveCondition == null ? "" : `WHERE ${archiveCondition}`;
|
|
24
|
+
|
|
25
|
+
return db
|
|
26
|
+
.query<Task, any>(`SELECT * FROM ${DB_TABLE.task}
|
|
27
|
+
${whereClause}
|
|
28
|
+
ORDER BY (${DB_TABLE.task}.archived_at IS NOT NULL) ASC`)
|
|
29
|
+
.all();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAllByProjectId(projectId: string, archive: TaskArchiveFilter = "active") {
|
|
33
|
+
const db = getDb();
|
|
34
|
+
const archiveCondition = getArchiveCondition(archive);
|
|
35
|
+
const archiveCluase = archiveCondition == null ? "" : `AND ${archiveCondition}`;
|
|
36
|
+
|
|
37
|
+
return db
|
|
38
|
+
.query<Task, Bind<Pick<Task, "project_id">>>(`SELECT task.*
|
|
39
|
+
FROM ${DB_TABLE.task}
|
|
40
|
+
INNER JOIN ${DB_TABLE.projectColumn}
|
|
41
|
+
ON project_column.id = task.column_id
|
|
42
|
+
WHERE task.project_id = $project_id
|
|
43
|
+
${archiveCluase}
|
|
44
|
+
ORDER BY project_column.column_order ASC,
|
|
45
|
+
(task.archived_at IS NOT NULL) ASC,
|
|
46
|
+
task.task_order ASC,
|
|
47
|
+
task.number ASC`)
|
|
48
|
+
.all({ $project_id: projectId });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getAllByColumnId(columnId: string, archive: TaskArchiveFilter = "active") {
|
|
52
|
+
const db = getDb();
|
|
53
|
+
const archiveCondition = getArchiveCondition(archive);
|
|
54
|
+
const archiveClause = archiveCondition == null ? "" : `AND ${archiveCondition}`;
|
|
55
|
+
|
|
56
|
+
return db
|
|
57
|
+
.query<Task, Bind<Pick<Task, "column_id">>>(`SELECT *
|
|
58
|
+
FROM ${DB_TABLE.task}
|
|
59
|
+
WHERE column_id = $column_id
|
|
60
|
+
${archiveClause}
|
|
61
|
+
ORDER BY task_order ASC,
|
|
62
|
+
number ASC`)
|
|
63
|
+
.all({ $column_id: columnId });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getLastByColumnId(columnId: string) {
|
|
67
|
+
const db = getDb();
|
|
68
|
+
return db
|
|
69
|
+
.query<Task, Bind<Pick<Task, "column_id">>>(`SELECT *
|
|
70
|
+
FROM ${DB_TABLE.task}
|
|
71
|
+
WHERE column_id = $column_id
|
|
72
|
+
AND archived_at IS NULL
|
|
73
|
+
ORDER BY task_order DESC
|
|
74
|
+
LIMIT 1`)
|
|
75
|
+
.get({ $column_id: columnId });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getByProjectIdAndNumber(props: { projectId: string; number: number }) {
|
|
79
|
+
const db = getDb();
|
|
80
|
+
return db
|
|
81
|
+
.query<Task, Bind<Pick<Task, "project_id" | "number">>>(`SELECT *
|
|
82
|
+
FROM ${DB_TABLE.task}
|
|
83
|
+
WHERE project_id = $project_id
|
|
84
|
+
AND number = $number`)
|
|
85
|
+
.get({ $project_id: props.projectId, $number: props.number });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function create(task: Omit<Task, "archived_at">) {
|
|
89
|
+
const db = getDb();
|
|
90
|
+
|
|
91
|
+
const q = db.query<Task, Bind<Omit<Task, "archived_at">>>(`INSERT INTO
|
|
92
|
+
${DB_TABLE.task} (id, project_id, column_id, number, title, description, task_order, created_at, updated_at)
|
|
93
|
+
VALUES ($id, $project_id, $column_id, $number, $title, $description, $task_order, $created_at, $updated_at);`);
|
|
94
|
+
|
|
95
|
+
return q.run({
|
|
96
|
+
$id: task.id,
|
|
97
|
+
$project_id: task.project_id,
|
|
98
|
+
$column_id: task.column_id,
|
|
99
|
+
$number: task.number,
|
|
100
|
+
$title: task.title,
|
|
101
|
+
$description: task.description,
|
|
102
|
+
$task_order: task.task_order,
|
|
103
|
+
$created_at: task.created_at,
|
|
104
|
+
$updated_at: task.updated_at,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const TASK_UPDATE_COLUMNS = [
|
|
109
|
+
"column_id",
|
|
110
|
+
"title",
|
|
111
|
+
"description",
|
|
112
|
+
"task_order",
|
|
113
|
+
"updated_at",
|
|
114
|
+
"archived_at",
|
|
115
|
+
] as const satisfies ReadonlyArray<keyof Task>;
|
|
116
|
+
|
|
117
|
+
type TaskUpdateColumn = (typeof TASK_UPDATE_COLUMNS)[number];
|
|
118
|
+
type TaskUpdateParams = Pick<Task, "id" | "updated_at"> &
|
|
119
|
+
Partial<Pick<Task, Exclude<TaskUpdateColumn, "updated_at">>>;
|
|
120
|
+
type TaskUpdateBindings = Record<string, Task[keyof Task]>;
|
|
121
|
+
|
|
122
|
+
export function update(props: TaskUpdateParams) {
|
|
123
|
+
const db = getDb();
|
|
124
|
+
|
|
125
|
+
const setClauses: Array<string> = [];
|
|
126
|
+
const bindings: TaskUpdateBindings = { $id: props.id };
|
|
127
|
+
|
|
128
|
+
for (const column of TASK_UPDATE_COLUMNS) {
|
|
129
|
+
const value = props[column];
|
|
130
|
+
if (value === undefined) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setClauses.push(`${column} = $${column}`);
|
|
135
|
+
bindings[`$${column}`] = value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return db
|
|
139
|
+
.prepare<Task, TaskUpdateBindings>(`UPDATE ${DB_TABLE.task}
|
|
140
|
+
SET ${setClauses.join(",\n ")}
|
|
141
|
+
WHERE id = $id
|
|
142
|
+
RETURNING *;`)
|
|
143
|
+
.get(bindings);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function deleteById(taskId: string) {
|
|
147
|
+
const db = getDb();
|
|
148
|
+
|
|
149
|
+
return db
|
|
150
|
+
.query<never, Bind<Pick<Task, "id">>>(`DELETE FROM ${DB_TABLE.task} WHERE id = $id;`)
|
|
151
|
+
.run({ $id: taskId });
|
|
152
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -1,34 +1,59 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { DB_PATH, DB_TABLE } from "./constant";
|
|
3
|
-
import {
|
|
3
|
+
import { ensureConfigSetup } from "./config";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
db.run("PRAGMA journal_mode = WAL;");
|
|
7
|
-
// TODO: https://bun.com/docs/runtime/sqlite#wal-sidecar-file-cleanup macOS does not auto cleanup
|
|
5
|
+
let db: Database | null = null;
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
function initDb() {
|
|
8
|
+
// TODO: turn on strict mode, and refactor `$` prefixes: https://bun.com/docs/runtime/sqlite#strict-true-lets-you-bind-values-without-prefixes
|
|
9
|
+
const db = new Database(DB_PATH, { create: true });
|
|
10
|
+
|
|
11
|
+
db.run("PRAGMA journal_mode = WAL;");
|
|
12
|
+
db.run("PRAGMA foreign_keys = true;");
|
|
13
|
+
// TODO: https://bun.com/docs/runtime/sqlite#wal-sidecar-file-cleanup macOS does not auto cleanup
|
|
14
|
+
|
|
15
|
+
// --- PROJECT TABLE
|
|
16
|
+
db.query(`CREATE TABLE IF NOT EXISTS ${DB_TABLE.project} (
|
|
10
17
|
id TEXT PRIMARY KEY,
|
|
11
18
|
key TEXT NOT NULL UNIQUE,
|
|
12
19
|
name TEXT NOT NULL,
|
|
20
|
+
task_count INTEGER NOT NULL DEFAULT 0,
|
|
13
21
|
created_at INTEGER NOT NULL,
|
|
14
22
|
updated_at INTEGER NOT NULL,
|
|
15
|
-
archived_at INTEGER);`);
|
|
16
|
-
projectTableSetup.run();
|
|
23
|
+
archived_at INTEGER);`).run();
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
// --- PROJECT COLUMN TABLE
|
|
26
|
+
db.query(`CREATE TABLE IF NOT EXISTS ${DB_TABLE.projectColumn} (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE,
|
|
29
|
+
name TEXT NOT NULL,
|
|
30
|
+
column_order TEXT);`).run();
|
|
21
31
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
// --- TASK TABLE
|
|
33
|
+
db.query(`CREATE TABLE IF NOT EXISTS ${DB_TABLE.task} (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
project_id TEXT NOT NULL REFERENCES ${DB_TABLE.project}(id) ON DELETE CASCADE,
|
|
36
|
+
column_id TEXT NOT NULL REFERENCES ${DB_TABLE.projectColumn}(id),
|
|
37
|
+
number INTEGER NOT NULL,
|
|
38
|
+
title TEXT NOT NULL,
|
|
39
|
+
description TEXT,
|
|
40
|
+
task_order TEXT,
|
|
41
|
+
created_at INTEGER NOT NULL,
|
|
42
|
+
updated_at INTEGER NOT NULL,
|
|
43
|
+
archived_at INTEGER);`).run();
|
|
28
44
|
|
|
29
|
-
return
|
|
45
|
+
return db;
|
|
30
46
|
}
|
|
31
47
|
|
|
32
|
-
export function
|
|
33
|
-
|
|
48
|
+
export function getDb() {
|
|
49
|
+
if (db == null) {
|
|
50
|
+
try {
|
|
51
|
+
ensureConfigSetup();
|
|
52
|
+
db = initDb();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new Error("There was a problem initializing Zinn", { cause: err });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return db;
|
|
34
59
|
}
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { validateTaskInput, taskEditSchema, taskCreateSchema } from "./task";
|
|
3
|
+
|
|
4
|
+
test("edit schemas retain explicit values and never default omitted fields", () => {
|
|
5
|
+
expect(taskEditSchema.parse({ taskKey: "TEST-1", description: "" })).toEqual({
|
|
6
|
+
taskKey: "TEST-1", description: "",
|
|
7
|
+
});
|
|
8
|
+
expect(taskEditSchema.parse({ taskKey: "TEST-1", title: " Title " })).toEqual({
|
|
9
|
+
taskKey: "TEST-1", title: " Title ",
|
|
10
|
+
});
|
|
11
|
+
expect(taskEditSchema.parse({ taskKey: "TEST-1", description: null }).description).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("edit input validation rejects empty, mistyped, and unsupported changes", () => {
|
|
15
|
+
for (const input of [
|
|
16
|
+
{ taskKey: "TEST-1" },
|
|
17
|
+
{ taskKey: "TEST-1", title: undefined },
|
|
18
|
+
{ taskKey: "TEST-1", title: 42 },
|
|
19
|
+
{ taskKey: "TEST-1", title: "Valid", column_id: "other" },
|
|
20
|
+
]) {
|
|
21
|
+
expect(() => validateTaskInput(taskEditSchema, input)).toThrow();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("creation and editing share content validation", () => {
|
|
26
|
+
for (const title of ["", " "]) {
|
|
27
|
+
expect(taskCreateSchema.safeParse({ project_id: "id", title, description: null }).success).toBe(false);
|
|
28
|
+
expect(taskEditSchema.safeParse({ taskKey: "TEST-1", title }).success).toBe(false);
|
|
29
|
+
}
|
|
30
|
+
expect(taskCreateSchema.parse({ project_id: "id", title: "Valid", description: "" }).description).toBe("");
|
|
31
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const titleMessage = "Task title cannot be blank";
|
|
4
|
+
|
|
5
|
+
export const taskTitleSchema = z
|
|
6
|
+
.string({ error: titleMessage })
|
|
7
|
+
.refine((value) => value.trim().length > 0, titleMessage);
|
|
8
|
+
|
|
9
|
+
export const taskDescriptionSchema = z.string().nullable();
|
|
10
|
+
|
|
11
|
+
export const taskCreateSchema = z.strictObject({
|
|
12
|
+
project_id: z.string(),
|
|
13
|
+
title: taskTitleSchema,
|
|
14
|
+
description: taskDescriptionSchema,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const taskEditSchema = z
|
|
18
|
+
.strictObject({
|
|
19
|
+
taskKey: z.string(),
|
|
20
|
+
title: taskTitleSchema.optional(),
|
|
21
|
+
description: taskDescriptionSchema.optional(),
|
|
22
|
+
})
|
|
23
|
+
.refine(
|
|
24
|
+
(value) => value.title !== undefined || value.description !== undefined,
|
|
25
|
+
"Provide at least one field to edit: title or description",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export type TaskCreateInput = z.infer<typeof taskCreateSchema>;
|
|
29
|
+
export type TaskEditInput = z.infer<typeof taskEditSchema>;
|
|
30
|
+
|
|
31
|
+
export function validateTaskInput<S extends z.ZodType>(schema: S, input: unknown): z.output<S> {
|
|
32
|
+
const result = schema.safeParse(input);
|
|
33
|
+
if (!result.success) {
|
|
34
|
+
throw new Error(result.error.issues.map((issue) => issue.message).join("\n"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result.data;
|
|
38
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
|
|
2
|
+
type BoundProperty<P extends string> = `$${P}`;
|
|
3
|
+
// https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
|
|
4
|
+
export type Bind<T, K extends string & keyof T = string & keyof T> = {
|
|
5
|
+
[Property in BoundProperty<K>]: T[keyof T];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type Project = {
|
|
9
|
+
id: string;
|
|
10
|
+
key: string;
|
|
11
|
+
name: string;
|
|
12
|
+
task_count: number;
|
|
13
|
+
created_at: number;
|
|
14
|
+
updated_at: number;
|
|
15
|
+
archived_at: number | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TaskMoveDirection = "top" | "up" | "down" | "bottom";
|
|
19
|
+
export type Task = {
|
|
20
|
+
id: string;
|
|
21
|
+
project_id: string;
|
|
22
|
+
column_id: string;
|
|
23
|
+
number: number;
|
|
24
|
+
title: string;
|
|
25
|
+
description: string | null;
|
|
26
|
+
task_order: string;
|
|
27
|
+
created_at: number;
|
|
28
|
+
updated_at: number;
|
|
29
|
+
archived_at: number | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type Column = {
|
|
33
|
+
id: string;
|
|
34
|
+
project_id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
column_order: string;
|
|
37
|
+
};
|