@zinn-dev/core 0.5.0 → 0.6.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,6 +1,7 @@
1
1
  import type { Column } from "./src/types";
2
2
  import type { ProjectCreateInput, ProjectEditInput } from "./src/schemas/project";
3
3
  import type { TaskCreateInput, TaskEditInput } from "./src/schemas/task";
4
+ import type { RelationCreateInput } from "./src/schemas/relation";
4
5
 
5
6
  import { randomUUIDv7 } from "bun";
6
7
  import { generateKeyBetween } from "fractional-indexing";
@@ -8,8 +9,10 @@ import { generateKeyBetween } from "fractional-indexing";
8
9
  import * as dbProject from "./src/db/project";
9
10
  import * as dbColumn from "./src/db/column";
10
11
  import * as dbTask from "./src/db/task";
12
+ import * as dbRelation from "./src/db/relation";
11
13
  import { standardizeProjectKey } from "./src/lib";
12
14
  import { validateTaskInput, taskCreateSchema, taskEditSchema } from "./src/schemas/task";
15
+ import { relationCreateSchema } from "./src/schemas/relation";
13
16
 
14
17
  import {
15
18
  validateProjectInput,
@@ -19,9 +22,11 @@ import {
19
22
 
20
23
  export type { ProjectCreateInput, ProjectEditInput } from "./src/schemas/project";
21
24
  export type { TaskCreateInput, TaskEditInput } from "./src/schemas/task";
25
+ export type { RelationCreateInput } from "./src/schemas/relation";
22
26
 
23
27
  export { projectCreateSchema, projectEditSchema } from "./src/schemas/project";
24
28
  export { taskCreateSchema, taskEditSchema } from "./src/schemas/task";
29
+ export { relationCreateSchema } from "./src/schemas/relation";
25
30
 
26
31
  export const project = {
27
32
  getById: dbProject.getById,
@@ -376,3 +381,54 @@ export const task = {
376
381
  })!;
377
382
  },
378
383
  };
384
+
385
+ export const relation = {
386
+ create: (input: RelationCreateInput) => {
387
+ const props = validateTaskInput(relationCreateSchema, input);
388
+ const sourceTask = getTaskByKey(props.sourceTaskKey);
389
+ const targetTask = getTaskByKey(props.targetTaskKey);
390
+
391
+ if (sourceTask.id === targetTask.id) {
392
+ throw new Error("A task cannot have a relation to itself. Not appropriate.");
393
+ }
394
+
395
+ let sourceId = sourceTask.id;
396
+ let targetId = targetTask.id;
397
+
398
+ /**
399
+ * To avoid headaches with relations that
400
+ * technically have no semantic source/target
401
+ * it felt reasonable to invent reliable "source" and "target"
402
+ * by enforcing the placements through ID comparison
403
+ */
404
+ const isSymmetricRelation = props.relation_type === "related";
405
+ if (isSymmetricRelation && sourceId > targetId) {
406
+ sourceId = targetTask.id;
407
+ targetId = sourceTask.id;
408
+ }
409
+
410
+ const relationMatch = dbRelation.getByTaskIdsAndType({
411
+ source_task_id: sourceId,
412
+ target_task_id: targetId,
413
+ relation_type: props.relation_type,
414
+ });
415
+
416
+ if (relationMatch != null) {
417
+ throw new Error("This relation already exists");
418
+ }
419
+
420
+ return dbRelation.create({
421
+ source_task_id: sourceId,
422
+ target_task_id: targetId,
423
+ relation_type: props.relation_type,
424
+ });
425
+ },
426
+ getAll: (taskKey: string) => {
427
+ if (taskKey == null) {
428
+ throw new Error("Provide a task key to list relations");
429
+ }
430
+ const taskMatch = getTaskByKey(taskKey);
431
+
432
+ return dbRelation.getAllByTaskId(taskMatch.id);
433
+ },
434
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zinn-dev/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Shared logic for zinn, a kanban workflow in the terminal.",
5
5
  "license": "MIT",
6
6
  "bugs": {
package/src/constant.ts CHANGED
@@ -14,6 +14,7 @@ export const DB_TABLE = {
14
14
  project: "project",
15
15
  projectColumn: "project_column",
16
16
  task: "task",
17
+ task_relations: "task_relations",
17
18
  };
18
19
 
19
20
  // TODO: implement zod schema for setting.json
@@ -0,0 +1,103 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ function runRelationCheck(script: string) {
7
+ const testDir = mkdtempSync(join(tmpdir(), "zinn-relations-"));
8
+ try {
9
+ // Separate process keeps the DB singleton and environment isolated from other tests.
10
+ const result = Bun.spawnSync([process.execPath, "--eval", `
11
+ import { strict as assert } from "node:assert";
12
+ import { project, task, relation } from ${JSON.stringify(new URL("../../index.ts", import.meta.url).pathname)};
13
+ import { getDb } from ${JSON.stringify(new URL("../db.ts", import.meta.url).pathname)};
14
+ ${script}
15
+ `], { env: { ...process.env, ZINN_DIR: testDir } });
16
+ expect(result.stderr.toString()).toBe("");
17
+ expect(result.exitCode).toBe(0);
18
+ } finally {
19
+ rmSync(testDir, { recursive: true, force: true });
20
+ }
21
+ }
22
+
23
+ for (const inputOrder of ["smaller first", "larger first"]) {
24
+ test(`related stores the smaller task ID as source with ${inputOrder}`, () => {
25
+ runRelationCheck(`
26
+ const app = project.create({ key: "APP" });
27
+ const tasks = [1, 2].map(() => task.create({
28
+ project_id: app.id, title: "Task", description: null,
29
+ })).sort((a, b) => a.id < b.id ? -1 : 1);
30
+ const [smaller, larger] = tasks;
31
+ const source = ${JSON.stringify(inputOrder)} === "smaller first" ? smaller : larger;
32
+ const target = source === smaller ? larger : smaller;
33
+ const create = (source, target) => relation.create({
34
+ sourceTaskKey: "APP-" + source.number,
35
+ targetTaskKey: "APP-" + target.number,
36
+ relation_type: "related",
37
+ });
38
+ const related = create(source, target);
39
+ assert.equal(related.source_task_id, smaller.id);
40
+ assert.equal(related.target_task_id, larger.id);
41
+ assert.throws(() => create(source, target), /already exists/);
42
+ assert.throws(() => create(target, source), /already exists/);
43
+ assert.equal(relation.getAll("APP-1").length, 1);
44
+ assert.equal(relation.getAll("APP-2")[0].id, related.id);
45
+ `);
46
+ });
47
+ }
48
+
49
+ test("dependency preserves the dependent task as source regardless of task ID order", () => {
50
+ runRelationCheck(`
51
+ const app = project.create({ key: "APP" });
52
+ const tasks = [1, 2].map(() => task.create({
53
+ project_id: app.id, title: "Task", description: null,
54
+ })).sort((a, b) => a.id < b.id ? -1 : 1);
55
+ for (const [dependent, prerequisite] of [tasks, [...tasks].reverse()]) {
56
+ const dependency = relation.create({
57
+ sourceTaskKey: "APP-" + dependent.number,
58
+ targetTaskKey: "APP-" + prerequisite.number,
59
+ relation_type: "dependency",
60
+ });
61
+ assert.equal(dependency.source_task_id, dependent.id);
62
+ assert.equal(dependency.target_task_id, prerequisite.id);
63
+ }
64
+ // A symmetric connection between the same tasks is a separate relation.
65
+ relation.create({ sourceTaskKey: "APP-1", targetTaskKey: "APP-2", relation_type: "related" });
66
+ assert.equal(relation.getAll("APP-1").length, 3);
67
+ `);
68
+ });
69
+
70
+ test("relations clean up without deleting connected tasks", () => {
71
+ runRelationCheck(`
72
+ const a = project.create({ key: "APP" });
73
+ const b = project.create({ key: "OTHER" });
74
+ for (let i = 0; i < 4; i++) {
75
+ task.create({ project_id: a.id, title: "Task", description: null });
76
+ }
77
+ task.create({ project_id: b.id, title: "Survivor", description: null });
78
+ const create = (sourceTaskKey, targetTaskKey, relation_type) =>
79
+ relation.create({ sourceTaskKey, targetTaskKey, relation_type });
80
+ const related = create("APP-2", "APP-1", "related");
81
+ assert.ok(related.id);
82
+ assert.ok(related.created_at > 0);
83
+ assert.ok(related.source_task_id < related.target_task_id);
84
+ assert.throws(() => create("APP-1", "APP-2", "related"), /already exists/);
85
+ assert.throws(() => create("APP-1", "APP-1", "related"), /itself/);
86
+ assert.equal(relation.getAll("APP-1")[0].id, related.id);
87
+ assert.equal(relation.getAll("APP-2")[0].id, related.id);
88
+ const dependency = create("APP-4", "APP-3", "dependency");
89
+ assert.equal(dependency.source_task_id, task.getByKey("APP-4").id);
90
+ assert.equal(dependency.target_task_id, task.getByKey("APP-3").id);
91
+ const preserved = create("APP-3", "OTHER-1", "related");
92
+ task.delete("APP-4");
93
+ assert.deepEqual(relation.getAll("APP-3").map(r => r.id), [preserved.id]);
94
+ create("APP-3", "APP-2", "dependency");
95
+ task.delete("APP-2");
96
+ assert.deepEqual(relation.getAll("APP-1"), []);
97
+ assert.deepEqual(relation.getAll("APP-3").map(r => r.id), [preserved.id]);
98
+ project.delete("APP");
99
+ assert.ok(task.getByKey("OTHER-1"));
100
+ assert.deepEqual(relation.getAll("OTHER-1"), []);
101
+ assert.deepEqual(getDb().query("PRAGMA foreign_key_check").all(), []);
102
+ `);
103
+ });
@@ -0,0 +1,58 @@
1
+ import type { Bind, TaskRelation } from "../types";
2
+
3
+ import { randomUUIDv7 } from "bun";
4
+
5
+ import { DB_TABLE } from "../constant";
6
+ import { getDb } from "../db";
7
+
8
+ export function getAll() {
9
+ const db = getDb();
10
+ return db.query<TaskRelation, any>(`SELECT * FROM ${DB_TABLE.task_relations}`).all();
11
+ }
12
+
13
+ export function getAllByTaskId(taskId: string) {
14
+ const db = getDb();
15
+
16
+ return db
17
+ .query<TaskRelation, Bind<{ taskId: string }>>(`SELECT * FROM ${DB_TABLE.task_relations}
18
+ WHERE source_task_id = $taskId OR target_task_id = $taskId`)
19
+ .all({ $taskId: taskId });
20
+ }
21
+
22
+ export function getByTaskIdsAndType(
23
+ props: Pick<TaskRelation, "source_task_id" | "target_task_id" | "relation_type">,
24
+ ) {
25
+ const db = getDb();
26
+
27
+ return db
28
+ .query<TaskRelation, Bind<typeof props>>(`SELECT * FROM ${DB_TABLE.task_relations}
29
+ WHERE source_task_id = $source_task_id
30
+ AND target_task_id = $target_task_id
31
+ AND relation_type = $relation_type`)
32
+ .get({
33
+ $source_task_id: props.source_task_id,
34
+ $target_task_id: props.target_task_id,
35
+ $relation_type: props.relation_type,
36
+ });
37
+ }
38
+
39
+ type CreateTaskRelationInput = Pick<
40
+ TaskRelation,
41
+ "id" | "source_task_id" | "target_task_id" | "relation_type" | "created_at"
42
+ >;
43
+ export function create(props: Omit<CreateTaskRelationInput, "id" | "created_at">) {
44
+ const db = getDb();
45
+
46
+ const query = db.query<TaskRelation, Bind<CreateTaskRelationInput>>(`INSERT INTO
47
+ ${DB_TABLE.task_relations} (id, source_task_id, target_task_id, relation_type, created_at)
48
+ VALUES ($id, $source_task_id, $target_task_id, $relation_type, $created_at)
49
+ RETURNING *;`);
50
+
51
+ return query.get({
52
+ $id: randomUUIDv7(),
53
+ $source_task_id: props.source_task_id,
54
+ $target_task_id: props.target_task_id,
55
+ $relation_type: props.relation_type,
56
+ $created_at: Date.now(),
57
+ });
58
+ }
package/src/db.ts CHANGED
@@ -42,6 +42,19 @@ function initDb() {
42
42
  updated_at INTEGER NOT NULL,
43
43
  archived_at INTEGER);`).run();
44
44
 
45
+ // --- TASK RELATIONS TABLE
46
+ db.query(`CREATE TABLE IF NOT EXISTS ${DB_TABLE.task_relations} (
47
+ id TEXT PRIMARY KEY,
48
+ source_task_id TEXT NOT NULL REFERENCES ${DB_TABLE.task}(id) ON DELETE CASCADE,
49
+ target_task_id TEXT NOT NULL REFERENCES ${DB_TABLE.task}(id) ON DELETE CASCADE,
50
+ relation_type TEXT NOT NULL CHECK (
51
+ relation_type IN ('related', 'dependency', 'duplicate')
52
+ ),
53
+ created_at INTEGER NOT NULL,
54
+
55
+ UNIQUE (source_task_id, target_task_id, relation_type),
56
+ CHECK (source_task_id <> target_task_id));`).run();
57
+
45
58
  return db;
46
59
  }
47
60
 
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+
3
+ export const relationCreateSchema = z.strictObject({
4
+ // For dependency, the source task depends on the target task.
5
+ sourceTaskKey: z.string(),
6
+ targetTaskKey: z.string(),
7
+ relation_type: z.enum(["related", "dependency", "duplicate"]),
8
+ });
9
+
10
+ export type RelationCreateInput = z.infer<typeof relationCreateSchema>;
package/src/types.ts CHANGED
@@ -5,6 +5,8 @@ export type Bind<T, K extends string & keyof T = string & keyof T> = {
5
5
  [Property in BoundProperty<K>]: T[keyof T];
6
6
  };
7
7
 
8
+ export type WithId<T extends { id: string }> = Pick<T, "id"> & Partial<Omit<T, "id">>;
9
+
8
10
  export type Project = {
9
11
  id: string;
10
12
  key: string;
@@ -15,7 +17,6 @@ export type Project = {
15
17
  archived_at: number | null;
16
18
  };
17
19
 
18
- export type TaskMoveDirection = "top" | "up" | "down" | "bottom";
19
20
  export type Task = {
20
21
  id: string;
21
22
  project_id: string;
@@ -29,6 +30,15 @@ export type Task = {
29
30
  archived_at: number | null;
30
31
  };
31
32
 
33
+ export type TaskRelationType = "related" | "dependency" | "duplicate";
34
+ export type TaskRelation = {
35
+ id: string;
36
+ source_task_id: string;
37
+ target_task_id: string;
38
+ relation_type: TaskRelationType;
39
+ created_at: number;
40
+ };
41
+
32
42
  export type Column = {
33
43
  id: string;
34
44
  project_id: string;