@zinn-dev/core 0.1.0 → 0.3.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 +46 -22
- package/package.json +1 -1
- package/src/db/project.ts +10 -0
- package/src/db/task.ts +3 -1
- package/src/schemas/project.test.ts +24 -0
- package/src/schemas/project.ts +35 -0
package/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Column } from "./src/types";
|
|
2
|
+
import type { ProjectCreateInput, ProjectEditInput } from "./src/schemas/project";
|
|
3
|
+
import type { TaskCreateInput, TaskEditInput } from "./src/schemas/task";
|
|
2
4
|
|
|
3
5
|
import { randomUUIDv7 } from "bun";
|
|
4
6
|
import { generateKeyBetween } from "fractional-indexing";
|
|
@@ -8,26 +10,24 @@ import * as dbColumn from "./src/db/column";
|
|
|
8
10
|
import * as dbTask from "./src/db/task";
|
|
9
11
|
import { standardizeProjectKey } from "./src/lib";
|
|
10
12
|
import { validateTaskInput, taskCreateSchema, taskEditSchema } from "./src/schemas/task";
|
|
11
|
-
import type { TaskCreateInput, TaskEditInput } from "./src/schemas/task";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
import {
|
|
15
|
+
validateProjectInput,
|
|
16
|
+
projectCreateSchema,
|
|
17
|
+
projectEditSchema,
|
|
18
|
+
} from "./src/schemas/project";
|
|
19
|
+
|
|
20
|
+
export type { ProjectCreateInput, ProjectEditInput } from "./src/schemas/project";
|
|
14
21
|
export type { TaskCreateInput, TaskEditInput } from "./src/schemas/task";
|
|
15
22
|
|
|
23
|
+
export { projectCreateSchema, projectEditSchema } from "./src/schemas/project";
|
|
24
|
+
export { taskCreateSchema, taskEditSchema } from "./src/schemas/task";
|
|
25
|
+
|
|
16
26
|
export const project = {
|
|
17
27
|
getById: dbProject.getById,
|
|
18
28
|
getByKey: dbProject.getByKey,
|
|
19
|
-
create: (
|
|
20
|
-
const
|
|
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
|
-
|
|
29
|
+
create: (input: ProjectCreateInput) => {
|
|
30
|
+
const props = validateProjectInput(projectCreateSchema, input);
|
|
31
31
|
const existingProject = dbProject.getByKey(props.key);
|
|
32
32
|
|
|
33
33
|
if (existingProject != null) {
|
|
@@ -49,6 +49,25 @@ export const project = {
|
|
|
49
49
|
})!;
|
|
50
50
|
lastColumnOrder = columnOrder;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
return project;
|
|
54
|
+
},
|
|
55
|
+
/** Rename a project. Same name does not alter the timestamp */
|
|
56
|
+
edit: (input: ProjectEditInput) => {
|
|
57
|
+
const props = validateProjectInput(projectEditSchema, input);
|
|
58
|
+
const projectMatch = dbProject.getByKey(props.projectKey);
|
|
59
|
+
|
|
60
|
+
if (projectMatch == null) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Project with key "${standardizeProjectKey(props.projectKey)}" does not exist!`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (props.name === projectMatch.name) {
|
|
67
|
+
return projectMatch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return dbProject.update({ id: projectMatch.id, name: props.name, updated_at: Date.now() })!;
|
|
52
71
|
},
|
|
53
72
|
getAll: dbProject.getAll,
|
|
54
73
|
delete: (key: string) => {
|
|
@@ -59,6 +78,7 @@ export const project = {
|
|
|
59
78
|
}
|
|
60
79
|
|
|
61
80
|
dbProject.deleteByKey(key);
|
|
81
|
+
return project;
|
|
62
82
|
},
|
|
63
83
|
standardizeKey: standardizeProjectKey,
|
|
64
84
|
};
|
|
@@ -94,12 +114,13 @@ export const column = {
|
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
const lastColOrder = allProjectColumns[allProjectColumns.length - 1]?.column_order ?? null;
|
|
97
|
-
|
|
117
|
+
|
|
118
|
+
return dbColumn.create({
|
|
98
119
|
id: randomUUIDv7(),
|
|
99
120
|
name: column.name,
|
|
100
121
|
column_order: generateKeyBetween(lastColOrder, null),
|
|
101
122
|
project_id: project.id,
|
|
102
|
-
})
|
|
123
|
+
})!;
|
|
103
124
|
},
|
|
104
125
|
};
|
|
105
126
|
|
|
@@ -205,7 +226,7 @@ export const task = {
|
|
|
205
226
|
title: props.title,
|
|
206
227
|
description: props.description,
|
|
207
228
|
updated_at: Date.now(),
|
|
208
|
-
})
|
|
229
|
+
})!;
|
|
209
230
|
},
|
|
210
231
|
/**
|
|
211
232
|
* Moves a task to another column in its project.
|
|
@@ -245,7 +266,7 @@ export const task = {
|
|
|
245
266
|
column_id: columnMatch.id,
|
|
246
267
|
task_order: order,
|
|
247
268
|
updated_at: Date.now(),
|
|
248
|
-
})
|
|
269
|
+
})!;
|
|
249
270
|
},
|
|
250
271
|
order: (props: TaskOrderProps) => {
|
|
251
272
|
const { taskKey, direction } = props;
|
|
@@ -317,11 +338,13 @@ export const task = {
|
|
|
317
338
|
futureNextTask?.task_order ?? null,
|
|
318
339
|
),
|
|
319
340
|
updated_at: Date.now(),
|
|
320
|
-
})
|
|
341
|
+
})!;
|
|
321
342
|
},
|
|
322
343
|
delete: (taskKey: string) => {
|
|
323
344
|
const taskMatch = getTaskByKey(taskKey);
|
|
324
|
-
|
|
345
|
+
dbTask.deleteById(taskMatch.id);
|
|
346
|
+
|
|
347
|
+
return taskMatch;
|
|
325
348
|
},
|
|
326
349
|
archive: (taskKey: string) => {
|
|
327
350
|
const taskMatch = getTaskByKey(taskKey);
|
|
@@ -331,7 +354,8 @@ export const task = {
|
|
|
331
354
|
}
|
|
332
355
|
|
|
333
356
|
const now = Date.now();
|
|
334
|
-
|
|
357
|
+
|
|
358
|
+
return dbTask.update({ id: taskMatch.id, updated_at: now, archived_at: now })!;
|
|
335
359
|
},
|
|
336
360
|
unarchive: (taskKey: string) => {
|
|
337
361
|
const taskMatch = getTaskByKey(taskKey);
|
|
@@ -348,6 +372,6 @@ export const task = {
|
|
|
348
372
|
task_order: order,
|
|
349
373
|
updated_at: Date.now(),
|
|
350
374
|
archived_at: null,
|
|
351
|
-
})
|
|
375
|
+
})!;
|
|
352
376
|
},
|
|
353
377
|
};
|
package/package.json
CHANGED
package/src/db/project.ts
CHANGED
|
@@ -71,3 +71,13 @@ export function deleteByKey(key: string) {
|
|
|
71
71
|
.query(`DELETE FROM ${DB_TABLE.project} WHERE key = $key;`)
|
|
72
72
|
.run({ $key: standardizedKey });
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
export function update(props: Pick<Project, "id" | "name" | "updated_at">) {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
|
|
78
|
+
return db
|
|
79
|
+
.query<Project, Bind<typeof props>>(`UPDATE ${DB_TABLE.project}
|
|
80
|
+
SET name = $name, updated_at = $updated_at
|
|
81
|
+
WHERE id = $id RETURNING *`)
|
|
82
|
+
.get({ $id: props.id, $name: props.name, $updated_at: props.updated_at });
|
|
83
|
+
}
|
package/src/db/task.ts
CHANGED
|
@@ -92,7 +92,7 @@ export function create(task: Omit<Task, "archived_at">) {
|
|
|
92
92
|
${DB_TABLE.task} (id, project_id, column_id, number, title, description, task_order, created_at, updated_at)
|
|
93
93
|
VALUES ($id, $project_id, $column_id, $number, $title, $description, $task_order, $created_at, $updated_at);`);
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
q.run({
|
|
96
96
|
$id: task.id,
|
|
97
97
|
$project_id: task.project_id,
|
|
98
98
|
$column_id: task.column_id,
|
|
@@ -103,6 +103,8 @@ export function create(task: Omit<Task, "archived_at">) {
|
|
|
103
103
|
$created_at: task.created_at,
|
|
104
104
|
$updated_at: task.updated_at,
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
return { ...task, archived_at: null };
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
const TASK_UPDATE_COLUMNS = [
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { projectCreateSchema, projectEditSchema, validateProjectInput } from "./project";
|
|
3
|
+
|
|
4
|
+
test("project creation and editing share name validation and preserve supplied text", () => {
|
|
5
|
+
for (const name of ["", " ", "Two\nlines", "Bad\u0000name"]) {
|
|
6
|
+
expect(projectCreateSchema.safeParse({ key: "TEST", name }).success).toBe(false);
|
|
7
|
+
expect(projectEditSchema.safeParse({ projectKey: "TEST", name }).success).toBe(false);
|
|
8
|
+
}
|
|
9
|
+
expect(projectEditSchema.parse({ projectKey: "test", name: " Name " })).toEqual({
|
|
10
|
+
projectKey: "test", name: " Name ",
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("project edits require a name and reject unsupported fields", () => {
|
|
15
|
+
for (const input of [
|
|
16
|
+
{ projectKey: "TEST" },
|
|
17
|
+
{ projectKey: "TEST", name: undefined },
|
|
18
|
+
{ projectKey: "TEST", name: null },
|
|
19
|
+
{ projectKey: "TEST", name: 42 },
|
|
20
|
+
{ projectKey: "TEST", name: "Name", key: "NEW" },
|
|
21
|
+
]) {
|
|
22
|
+
expect(() => validateProjectInput(projectEditSchema, input)).toThrow();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const nameMessage = "Project name must contain printable text on a single line";
|
|
4
|
+
const invalidNameCharRegex = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
5
|
+
|
|
6
|
+
export const projectNameSchema = z
|
|
7
|
+
.string({ error: nameMessage })
|
|
8
|
+
.refine((value) => value.trim().length > 0 && !invalidNameCharRegex.test(value), nameMessage);
|
|
9
|
+
|
|
10
|
+
export const projectCreateSchema = z.strictObject({
|
|
11
|
+
key: z
|
|
12
|
+
.string()
|
|
13
|
+
.regex(
|
|
14
|
+
/^[A-Za-z][A-Za-z0-9]*$/,
|
|
15
|
+
"Project key must start with a letter and contain only letters and numbers",
|
|
16
|
+
),
|
|
17
|
+
name: projectNameSchema,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const projectEditSchema = z.strictObject({
|
|
21
|
+
projectKey: z.string(),
|
|
22
|
+
name: projectNameSchema,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>;
|
|
26
|
+
export type ProjectEditInput = z.infer<typeof projectEditSchema>;
|
|
27
|
+
|
|
28
|
+
export function validateProjectInput<S extends z.ZodType>(schema: S, input: unknown): z.output<S> {
|
|
29
|
+
const result = schema.safeParse(input);
|
|
30
|
+
if (!result.success) {
|
|
31
|
+
throw new Error(result.error.issues.map((issue) => issue.message).join("\n"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result.data;
|
|
35
|
+
}
|