@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 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
- export { taskCreateSchema, taskEditSchema } from "./src/schemas/task";
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: (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
-
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
- const res = dbColumn.create({
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
- return dbTask.deleteById(taskMatch.id);
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
- return dbTask.update({ id: taskMatch.id, updated_at: now, archived_at: now });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zinn-dev/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared logic for zinn, a kanban workflow in the terminal.",
5
5
  "repository": {
6
6
  "type": "git",
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
- return q.run({
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
+ }