@zinn-dev/core 0.3.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/LICENSE +9 -0
- package/index.ts +58 -1
- package/package.json +12 -2
- package/src/constant.ts +1 -0
- package/src/db/relation.test.ts +103 -0
- package/src/db/relation.ts +58 -0
- package/src/db.ts +13 -0
- package/src/schemas/project.test.ts +5 -0
- package/src/schemas/project.ts +1 -1
- package/src/schemas/relation.ts +10 -0
- package/src/types.ts +11 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Zinn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
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,
|
|
@@ -34,7 +39,8 @@ export const project = {
|
|
|
34
39
|
throw new Error(`Project with key "${existingProject.key}" already exists!`);
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
const
|
|
42
|
+
const key = standardizeProjectKey(props.key);
|
|
43
|
+
const project = dbProject.create({ key, name: props.name ?? key })!;
|
|
38
44
|
|
|
39
45
|
const defaultColumns = ["Backlog", "TODO", "In Progress", "Review", "Done"];
|
|
40
46
|
let lastColumnOrder: string | null = null;
|
|
@@ -375,3 +381,54 @@ export const task = {
|
|
|
375
381
|
})!;
|
|
376
382
|
},
|
|
377
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,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zinn-dev/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Shared logic for zinn, a kanban workflow in the terminal.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/yethranayeh/zinn/issues"
|
|
8
|
+
},
|
|
9
|
+
"author": {
|
|
10
|
+
"name": "Alper Halil",
|
|
11
|
+
"email": "contact@aktasalper.com",
|
|
12
|
+
"url": "https://aktasalper.com"
|
|
13
|
+
},
|
|
5
14
|
"repository": {
|
|
6
15
|
"type": "git",
|
|
7
16
|
"url": "git+https://github.com/yethranayeh/zinn.git",
|
|
@@ -18,7 +27,8 @@
|
|
|
18
27
|
},
|
|
19
28
|
"files": [
|
|
20
29
|
"index.ts",
|
|
21
|
-
"src"
|
|
30
|
+
"src",
|
|
31
|
+
"LICENSE"
|
|
22
32
|
],
|
|
23
33
|
"publishConfig": {
|
|
24
34
|
"access": "public"
|
package/src/constant.ts
CHANGED
|
@@ -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
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { projectCreateSchema, projectEditSchema, validateProjectInput } from "./project";
|
|
3
3
|
|
|
4
|
+
test("project creation accepts an omitted name but rejects null", () => {
|
|
5
|
+
expect(projectCreateSchema.parse({ key: "test" })).toEqual({ key: "test" });
|
|
6
|
+
expect(projectCreateSchema.safeParse({ key: "TEST", name: null }).success).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
4
9
|
test("project creation and editing share name validation and preserve supplied text", () => {
|
|
5
10
|
for (const name of ["", " ", "Two\nlines", "Bad\u0000name"]) {
|
|
6
11
|
expect(projectCreateSchema.safeParse({ key: "TEST", name }).success).toBe(false);
|
package/src/schemas/project.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const projectCreateSchema = z.strictObject({
|
|
|
14
14
|
/^[A-Za-z][A-Za-z0-9]*$/,
|
|
15
15
|
"Project key must start with a letter and contain only letters and numbers",
|
|
16
16
|
),
|
|
17
|
-
name: projectNameSchema,
|
|
17
|
+
name: projectNameSchema.optional(),
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
export const projectEditSchema = z.strictObject({
|
|
@@ -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;
|