@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 +56 -0
- package/package.json +1 -1
- 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/relation.ts +10 -0
- package/src/types.ts +11 -1
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
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
|
|
|
@@ -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;
|