@zinn-dev/core 0.0.5 → 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 CHANGED
@@ -1,70 +1,353 @@
1
- import type { Task } from "./src/types";
1
+ import type { Column } from "./src/types";
2
2
 
3
3
  import { randomUUIDv7 } from "bun";
4
4
  import { generateKeyBetween } from "fractional-indexing";
5
5
 
6
- // TODO: do not initialize before the first "valid" command
7
- import "./src/config";
8
6
  import * as dbProject from "./src/db/project";
7
+ import * as dbColumn from "./src/db/column";
9
8
  import * as dbTask from "./src/db/task";
10
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";
11
15
 
12
16
  export const project = {
17
+ getById: dbProject.getById,
13
18
  getByKey: dbProject.getByKey,
14
- create: ({ key, name }: { key: string; name: string }) => {
15
- const standardizedKey = standardizeProjectKey(key);
16
- const project = dbProject.getByKey(standardizedKey);
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
+ }
17
30
 
18
- if (project != null) {
19
- throw new Error(`Project with key "${standardizedKey}" already exists!`);
31
+ const existingProject = dbProject.getByKey(props.key);
32
+
33
+ if (existingProject != null) {
34
+ throw new Error(`Project with key "${existingProject.key}" already exists!`);
20
35
  }
21
36
 
22
- dbProject.create(standardizedKey, name);
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
+ }
23
52
  },
53
+ getAll: dbProject.getAll,
24
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) => {
25
69
  const standardizedKey = standardizeProjectKey(key);
26
70
  const project = dbProject.getByKey(standardizedKey);
27
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);
80
+
28
81
  if (project == null) {
29
82
  throw new Error(`Project with key "${standardizedKey}" does not exist!`);
30
83
  }
31
84
 
32
- dbProject.deleteByKey(standardizedKey);
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
+ });
33
103
  },
34
- standardizeKey: standardizeProjectKey,
35
104
  };
36
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`);
110
+ }
111
+
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
+ }
117
+
118
+ const project = dbProject.getByKey(projectKey);
119
+
120
+ if (project == null) {
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!`);
131
+ }
132
+
133
+ return taskMatch;
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
+
37
148
  export const task = {
38
- create: (task: Pick<Task, "project_id" | "name" | "description">) => {
149
+ create: (input: TaskCreateInput) => {
150
+ const task = validateTaskInput(taskCreateSchema, input);
151
+
39
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
40
154
  const project = dbProject.incrementTaskCounterById(task.project_id);
41
155
 
42
156
  if (project == null) {
43
157
  throw new Error(`Project's next task number could not be retrieved (ID:${task.project_id})`);
44
158
  }
45
159
 
46
- // TODO: do proper refined query
47
- // FIXME: returns all tasks from *all* projects
48
- const allTasks = dbTask.getAll();
49
-
50
- let previousTaskOrder = null;
51
- if (allTasks.length > 0) {
52
- const lastTask = allTasks[allTasks.length - 1]!;
53
- previousTaskOrder = lastTask.task_order;
54
- }
55
-
56
- const order = generateKeyBetween(previousTaskOrder, null);
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);
57
163
 
58
164
  const now = Date.now();
59
165
  return dbTask.create({
60
166
  id: randomUUIDv7(),
61
167
  project_id: task.project_id,
168
+ column_id: initialColumn.id,
62
169
  number: project.task_count,
63
- name: task.name,
170
+ title: task.title,
64
171
  description: task.description,
65
172
  task_order: order,
66
173
  created_at: now,
67
174
  updated_at: now,
68
175
  });
69
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
+ },
70
353
  };
package/package.json CHANGED
@@ -1,29 +1,30 @@
1
1
  {
2
- "name": "@zinn-dev/core",
3
- "version": "0.0.5",
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
- }
29
- }
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
- if (!existsSync(MAIN_DIR)) {
5
- mkdirSync(MAIN_DIR);
6
- }
4
+ export function ensureConfigSetup() {
5
+ if (!existsSync(MAIN_DIR)) {
6
+ mkdirSync(MAIN_DIR);
7
+ }
7
8
 
8
- if (!existsSync(DATA_DIR)) {
9
- mkdirSync(DATA_DIR);
10
- }
9
+ if (!existsSync(DATA_DIR)) {
10
+ mkdirSync(DATA_DIR);
11
+ }
11
12
 
12
- if (!existsSync(CONFIG_PATH)) {
13
- writeFileSync(CONFIG_PATH, JSON.stringify(defaultConfig) + "\n", { encoding: "utf-8" });
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,14 @@ 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",
15
16
  task: "task",
16
17
  };
17
18
 
@@ -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
+ });
package/src/db/project.ts CHANGED
@@ -6,6 +6,19 @@ import { DB_TABLE } from "../constant";
6
6
  import { getDb } from "../db";
7
7
  import { standardizeProjectKey } from "../lib";
8
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
+
9
22
  export function getByKey(key: string) {
10
23
  const db = getDb();
11
24
  const standardizedKey = standardizeProjectKey(key);
@@ -15,16 +28,27 @@ export function getByKey(key: string) {
15
28
  .get({ $key: standardizedKey });
16
29
  }
17
30
 
18
- // TODO: switch to object param
19
- export function create(key: string, name: string) {
31
+ export function create({ key, name }: { key: string; name: string }) {
20
32
  const db = getDb();
21
33
 
22
- const query = db.query(`INSERT INTO
34
+ const query = db.query<
35
+ Project,
36
+ Bind<Pick<Project, "id" | "key" | "name" | "created_at" | "updated_at">>
37
+ >(`INSERT INTO
23
38
  ${DB_TABLE.project} (id, key, name, created_at, updated_at)
24
- VALUES ($id, $key, $name, $created, $updated);`);
39
+ VALUES ($id, $key, $name, $created_at, $updated_at)
40
+ RETURNING *;`);
41
+
25
42
  const time = Date.now();
43
+ const standardizedKey = standardizeProjectKey(key);
26
44
 
27
- return query.run({ $id: randomUUIDv7(), $key: key, $name: name, $created: time, $updated: time });
45
+ return query.get({
46
+ $id: randomUUIDv7(),
47
+ $key: standardizedKey,
48
+ $name: name,
49
+ $created_at: time,
50
+ $updated_at: time,
51
+ });
28
52
  }
29
53
 
30
54
  export function incrementTaskCounterById(projectId: string) {
@@ -42,5 +66,8 @@ export function incrementTaskCounterById(projectId: string) {
42
66
  export function deleteByKey(key: string) {
43
67
  const db = getDb();
44
68
 
45
- return db.query(`DELETE FROM ${DB_TABLE.project} WHERE key = $key;`).run({ $key: key });
69
+ const standardizedKey = standardizeProjectKey(key);
70
+ return db
71
+ .query(`DELETE FROM ${DB_TABLE.project} WHERE key = $key;`)
72
+ .run({ $key: standardizedKey });
46
73
  }
package/src/db/task.ts CHANGED
@@ -3,27 +3,150 @@ import type { Bind, Task } from "../types";
3
3
  import { getDb } from "../db";
4
4
  import { DB_TABLE } from "../constant";
5
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
+
6
19
  // TODO: refined parameters, preferably of type SQLBindings
7
- export function getAll() {
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) {
8
67
  const db = getDb();
9
- return db.query<Task, any>(`SELECT * FROM ${DB_TABLE.task}`).all();
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 });
10
86
  }
11
87
 
12
88
  export function create(task: Omit<Task, "archived_at">) {
13
89
  const db = getDb();
14
90
 
15
91
  const q = db.query<Task, Bind<Omit<Task, "archived_at">>>(`INSERT INTO
16
- ${DB_TABLE.task} (id, project_id, number, name, description, task_order, created_at, updated_at)
17
- VALUES ($id, $project_id, $number, $name, $description, $task_order, $created_at, $updated_at);`);
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);`);
18
94
 
19
95
  return q.run({
20
96
  $id: task.id,
21
97
  $project_id: task.project_id,
98
+ $column_id: task.column_id,
22
99
  $number: task.number,
23
- $name: task.name,
100
+ $title: task.title,
24
101
  $description: task.description,
25
102
  $task_order: task.task_order,
26
103
  $created_at: task.created_at,
27
104
  $updated_at: task.updated_at,
28
105
  });
29
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,5 +1,6 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { DB_PATH, DB_TABLE } from "./constant";
3
+ import { ensureConfigSetup } from "./config";
3
4
 
4
5
  let db: Database | null = null;
5
6
 
@@ -21,12 +22,20 @@ function initDb() {
21
22
  updated_at INTEGER NOT NULL,
22
23
  archived_at INTEGER);`).run();
23
24
 
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();
31
+
24
32
  // --- TASK TABLE
25
33
  db.query(`CREATE TABLE IF NOT EXISTS ${DB_TABLE.task} (
26
34
  id TEXT PRIMARY KEY,
27
- project_id TEXT NOT NULL REFERENCES project(id) ON DELETE CASCADE,
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),
28
37
  number INTEGER NOT NULL,
29
- name TEXT NOT NULL,
38
+ title TEXT NOT NULL,
30
39
  description TEXT,
31
40
  task_order TEXT,
32
41
  created_at INTEGER NOT NULL,
@@ -36,12 +45,14 @@ function initDb() {
36
45
  return db;
37
46
  }
38
47
 
39
- db = initDb();
40
-
41
48
  export function getDb() {
42
49
  if (db == null) {
43
- console.error("There was a problem initializing the database");
44
- process.exit(1);
50
+ try {
51
+ ensureConfigSetup();
52
+ db = initDb();
53
+ } catch (err) {
54
+ throw new Error("There was a problem initializing Zinn", { cause: err });
55
+ }
45
56
  }
46
57
 
47
58
  return db;
@@ -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 CHANGED
@@ -15,14 +15,23 @@ export type Project = {
15
15
  archived_at: number | null;
16
16
  };
17
17
 
18
+ export type TaskMoveDirection = "top" | "up" | "down" | "bottom";
18
19
  export type Task = {
19
20
  id: string;
20
21
  project_id: string;
22
+ column_id: string;
21
23
  number: number;
22
- name: string;
24
+ title: string;
23
25
  description: string | null;
24
26
  task_order: string;
25
27
  created_at: number;
26
28
  updated_at: number;
27
29
  archived_at: number | null;
28
30
  };
31
+
32
+ export type Column = {
33
+ id: string;
34
+ project_id: string;
35
+ name: string;
36
+ column_order: string;
37
+ };