@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 CHANGED
@@ -1,31 +1,353 @@
1
- // TODO: do not initialize before the first "valid" command
2
- import "./src/config";
3
- import { db, addProject, getProjectByKey, deleteProjectByKey } from "./src/db";
1
+ import type { Column } from "./src/types";
4
2
 
5
- function standardizeKey(key: string) {
6
- // TODO: maybe force latin characters only to prevent unexpected stuff from charaters like Ğ, İ, etc.
7
- return key.toUpperCase();
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
- // TODO: switch to object param
11
- export function createProject(key: string, name: string) {
12
- const standardizedKey = standardizeKey(key);
13
- const project = getProjectByKey(standardizedKey);
81
+ if (project == null) {
82
+ throw new Error(`Project with key "${standardizedKey}" does not exist!`);
83
+ }
14
84
 
15
- if (project != null) {
16
- throw new Error(`Project with key "${standardizedKey}" already exists!`);
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
- addProject(standardizedKey, name);
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
- export function deleteProject(key: string) {
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 "${standardizedKey}" does not exist!`);
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
- deleteProjectByKey(standardizedKey);
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
- "name": "@zinn-dev/core",
3
- "version": "0.0.4",
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
- }
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,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
@@ -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 { randomUUIDv7 } from "bun";
3
+ import { ensureConfigSetup } from "./config";
4
4
 
5
- export const db = new Database(DB_PATH, { create: true });
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
- const projectTableSetup = db.query(`CREATE TABLE IF NOT EXISTS ${DB_TABLE.project} (
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
- export function getProjectByKey(key: string) {
19
- return db.query(`SELECT * FROM ${DB_TABLE.project} WHERE key = $key`).get({ $key: key });
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
- // TODO: switch to object param
23
- export function addProject(key: string, name: string) {
24
- const query = db.query(`INSERT INTO
25
- ${DB_TABLE.project} (id, key, name, created_at, updated_at)
26
- VALUES ($id, $key, $name, $created, $updated);`);
27
- const time = Date.now();
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 query.run({ $id: randomUUIDv7(), $key: key, $name: name, $created: time, $updated: time });
45
+ return db;
30
46
  }
31
47
 
32
- export function deleteProjectByKey(key: string) {
33
- return db.query(`DELETE FROM ${DB_TABLE.project} WHERE key = $key;`).run({ $key: key });
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,4 @@
1
+ export function standardizeProjectKey(key: string) {
2
+ // TODO: maybe force latin characters only to prevent unexpected stuff from charaters like Ğ, İ, etc.
3
+ return key.toUpperCase();
4
+ }
@@ -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
+ };