@sqaoss/flowy 1.8.0 → 1.9.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/package.json +1 -1
- package/server/src/contract.test.ts +38 -5
- package/server/src/resolvers.test.ts +121 -1
- package/server/src/resolvers.ts +43 -8
- package/server/src/schema.ts +19 -1
- package/src/commands/tree.test.ts +89 -1
- package/src/commands/tree.ts +6 -0
- package/src/util/operations.ts +5 -4
package/package.json
CHANGED
|
@@ -281,11 +281,44 @@ describe('CLI/local-server contract (P1-1)', () => {
|
|
|
281
281
|
})
|
|
282
282
|
expect(exportDescendants.descendants.length).toBeGreaterThan(0)
|
|
283
283
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
// SUBTREE follows one relation (default part_of) and annotates each node
|
|
285
|
+
// with parentId/depth/relation. From the project, the part_of view is:
|
|
286
|
+
// project -> feature (depth 1) -> {task, blocker, imported} (depth 2),
|
|
287
|
+
// all reached via part_of edges. The blocker -> task `blocks` edge must NOT
|
|
288
|
+
// change anyone's parent/relation: every returned node carries
|
|
289
|
+
// relation 'part_of' and its part_of parent, never the blocks linkage.
|
|
290
|
+
const subtree = await ok<{
|
|
291
|
+
subtree: Array<{
|
|
292
|
+
id: string
|
|
293
|
+
parentId: string
|
|
294
|
+
depth: number
|
|
295
|
+
relation: string
|
|
296
|
+
}>
|
|
297
|
+
}>(LOCAL_CONTRACT_OPERATIONS.SUBTREE, {
|
|
298
|
+
nodeId: project.id,
|
|
299
|
+
relation: 'part_of',
|
|
300
|
+
maxDepth: 10,
|
|
301
|
+
})
|
|
302
|
+
const subtreeById = new Map(subtree.subtree.map((n) => [n.id, n]))
|
|
303
|
+
expect(subtreeById.get(feature.id)).toMatchObject({
|
|
304
|
+
parentId: project.id,
|
|
305
|
+
depth: 1,
|
|
306
|
+
relation: 'part_of',
|
|
307
|
+
})
|
|
308
|
+
expect(subtreeById.get(task.id)).toMatchObject({
|
|
309
|
+
parentId: feature.id,
|
|
310
|
+
depth: 2,
|
|
311
|
+
relation: 'part_of',
|
|
312
|
+
})
|
|
313
|
+
// the blocker is reached via its part_of edge to the feature (depth 2),
|
|
314
|
+
// NOT via the blocks edge that points at the task — proving blocks edges
|
|
315
|
+
// do not leak into the hierarchy view.
|
|
316
|
+
expect(subtreeById.get(blocker.id)).toMatchObject({
|
|
317
|
+
parentId: feature.id,
|
|
318
|
+
depth: 2,
|
|
319
|
+
relation: 'part_of',
|
|
320
|
+
})
|
|
321
|
+
expect(subtree.subtree.every((n) => n.relation === 'part_of')).toBe(true)
|
|
289
322
|
|
|
290
323
|
// task is blocked by an unfinished blocker, so readyTasks excludes it.
|
|
291
324
|
const ready = await ok<{ readyTasks: Array<{ id: string }> }>(
|
|
@@ -580,7 +580,7 @@ describe('createResolvers', () => {
|
|
|
580
580
|
})
|
|
581
581
|
|
|
582
582
|
describe('Query.subtree', () => {
|
|
583
|
-
it('returns
|
|
583
|
+
it('returns the part_of hierarchy by default', () => {
|
|
584
584
|
const project = resolvers.Mutation.createNode(null, {
|
|
585
585
|
type: 'project',
|
|
586
586
|
title: 'Root',
|
|
@@ -608,6 +608,125 @@ describe('createResolvers', () => {
|
|
|
608
608
|
expect(tree).toHaveLength(2)
|
|
609
609
|
})
|
|
610
610
|
|
|
611
|
+
it('returns parentId, depth and relation for each node (default part_of)', () => {
|
|
612
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
613
|
+
type: 'project',
|
|
614
|
+
title: 'Root',
|
|
615
|
+
})
|
|
616
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
617
|
+
type: 'feature',
|
|
618
|
+
title: 'F1',
|
|
619
|
+
})
|
|
620
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
621
|
+
type: 'task',
|
|
622
|
+
title: 'T1',
|
|
623
|
+
})
|
|
624
|
+
resolvers.Mutation.createEdge(null, {
|
|
625
|
+
sourceId: feature.id,
|
|
626
|
+
targetId: project.id,
|
|
627
|
+
relation: 'part_of',
|
|
628
|
+
})
|
|
629
|
+
resolvers.Mutation.createEdge(null, {
|
|
630
|
+
sourceId: task.id,
|
|
631
|
+
targetId: feature.id,
|
|
632
|
+
relation: 'part_of',
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
const tree = resolvers.Query.subtree(null, { nodeId: project.id })
|
|
636
|
+
const byId = new Map(tree.map((n) => [n.id, n]))
|
|
637
|
+
|
|
638
|
+
const featNode = byId.get(feature.id)
|
|
639
|
+
expect(featNode).toBeDefined()
|
|
640
|
+
expect(featNode?.depth).toBe(1)
|
|
641
|
+
expect(featNode?.parentId).toBe(project.id)
|
|
642
|
+
expect(featNode?.relation).toBe('part_of')
|
|
643
|
+
|
|
644
|
+
const taskNode = byId.get(task.id)
|
|
645
|
+
expect(taskNode).toBeDefined()
|
|
646
|
+
expect(taskNode?.depth).toBe(2)
|
|
647
|
+
expect(taskNode?.parentId).toBe(feature.id)
|
|
648
|
+
expect(taskNode?.relation).toBe('part_of')
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('excludes blocks edges from the default part_of view', () => {
|
|
652
|
+
const project = resolvers.Mutation.createNode(null, {
|
|
653
|
+
type: 'project',
|
|
654
|
+
title: 'Root',
|
|
655
|
+
})
|
|
656
|
+
const feature = resolvers.Mutation.createNode(null, {
|
|
657
|
+
type: 'feature',
|
|
658
|
+
title: 'F1',
|
|
659
|
+
})
|
|
660
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
661
|
+
type: 'task',
|
|
662
|
+
title: 'T1',
|
|
663
|
+
})
|
|
664
|
+
const blocker = resolvers.Mutation.createNode(null, {
|
|
665
|
+
type: 'task',
|
|
666
|
+
title: 'Blocker',
|
|
667
|
+
})
|
|
668
|
+
resolvers.Mutation.createEdge(null, {
|
|
669
|
+
sourceId: feature.id,
|
|
670
|
+
targetId: project.id,
|
|
671
|
+
relation: 'part_of',
|
|
672
|
+
})
|
|
673
|
+
resolvers.Mutation.createEdge(null, {
|
|
674
|
+
sourceId: task.id,
|
|
675
|
+
targetId: feature.id,
|
|
676
|
+
relation: 'part_of',
|
|
677
|
+
})
|
|
678
|
+
// blocker blocks task: source=blocker, target=task. A blocks edge must NOT
|
|
679
|
+
// pull the blocker into the project's part_of hierarchy view.
|
|
680
|
+
resolvers.Mutation.createEdge(null, {
|
|
681
|
+
sourceId: blocker.id,
|
|
682
|
+
targetId: task.id,
|
|
683
|
+
relation: 'blocks',
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
const tree = resolvers.Query.subtree(null, { nodeId: project.id })
|
|
687
|
+
const ids = tree.map((n) => n.id)
|
|
688
|
+
expect(ids).toContain(feature.id)
|
|
689
|
+
expect(ids).toContain(task.id)
|
|
690
|
+
expect(ids).not.toContain(blocker.id)
|
|
691
|
+
expect(tree.every((n) => n.relation === 'part_of')).toBe(true)
|
|
692
|
+
})
|
|
693
|
+
|
|
694
|
+
it('follows only the requested relation when overridden', () => {
|
|
695
|
+
const task = resolvers.Mutation.createNode(null, {
|
|
696
|
+
type: 'task',
|
|
697
|
+
title: 'Target',
|
|
698
|
+
})
|
|
699
|
+
const blocker = resolvers.Mutation.createNode(null, {
|
|
700
|
+
type: 'task',
|
|
701
|
+
title: 'Blocker',
|
|
702
|
+
})
|
|
703
|
+
const child = resolvers.Mutation.createNode(null, {
|
|
704
|
+
type: 'task',
|
|
705
|
+
title: 'Part of target',
|
|
706
|
+
})
|
|
707
|
+
// blocker blocks task (source=blocker, target=task)
|
|
708
|
+
resolvers.Mutation.createEdge(null, {
|
|
709
|
+
sourceId: blocker.id,
|
|
710
|
+
targetId: task.id,
|
|
711
|
+
relation: 'blocks',
|
|
712
|
+
})
|
|
713
|
+
// child is part_of task (source=child, target=task)
|
|
714
|
+
resolvers.Mutation.createEdge(null, {
|
|
715
|
+
sourceId: child.id,
|
|
716
|
+
targetId: task.id,
|
|
717
|
+
relation: 'part_of',
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
const blocks = resolvers.Query.subtree(null, {
|
|
721
|
+
nodeId: task.id,
|
|
722
|
+
relation: 'blocks',
|
|
723
|
+
})
|
|
724
|
+
expect(blocks.map((n) => n.id)).toEqual([blocker.id])
|
|
725
|
+
expect(blocks[0].relation).toBe('blocks')
|
|
726
|
+
expect(blocks[0].depth).toBe(1)
|
|
727
|
+
expect(blocks[0].parentId).toBe(task.id)
|
|
728
|
+
})
|
|
729
|
+
|
|
611
730
|
it('respects maxDepth', () => {
|
|
612
731
|
const project = resolvers.Mutation.createNode(null, {
|
|
613
732
|
type: 'project',
|
|
@@ -638,6 +757,7 @@ describe('createResolvers', () => {
|
|
|
638
757
|
})
|
|
639
758
|
expect(shallow).toHaveLength(1)
|
|
640
759
|
expect(shallow[0].id).toBe(feature.id)
|
|
760
|
+
expect(shallow[0].depth).toBe(1)
|
|
641
761
|
})
|
|
642
762
|
})
|
|
643
763
|
|
package/server/src/resolvers.ts
CHANGED
|
@@ -40,6 +40,23 @@ export interface NodeGql {
|
|
|
40
40
|
updatedAt: string
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* A node returned from a subtree traversal, annotated with the edge that pulled
|
|
45
|
+
* it in: `parentId` (the node it descends from), `depth` (1 for the root's
|
|
46
|
+
* direct children), and `relation` (the relation of the linking edge).
|
|
47
|
+
*/
|
|
48
|
+
export interface SubtreeNodeGql extends NodeGql {
|
|
49
|
+
parentId: string
|
|
50
|
+
depth: number
|
|
51
|
+
relation: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SubtreeRow extends NodeRow {
|
|
55
|
+
parent_id: string
|
|
56
|
+
depth: number
|
|
57
|
+
edge_relation: string
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
const NODE_COLS =
|
|
44
61
|
'id, type, title, description, status, metadata, created_at, updated_at'
|
|
45
62
|
|
|
@@ -177,21 +194,39 @@ export function createResolvers(db: Db) {
|
|
|
177
194
|
return selectNodes(rows)
|
|
178
195
|
},
|
|
179
196
|
|
|
180
|
-
|
|
197
|
+
// Walk the part_of hierarchy (or another `relation`, default 'part_of')
|
|
198
|
+
// downward from `nodeId`, returning each reachable node annotated with the
|
|
199
|
+
// edge that pulled it in: parentId, depth (root's direct children = 1) and
|
|
200
|
+
// relation. Following a single relation keeps the hierarchy view clean —
|
|
201
|
+
// `blocks` dependency edges no longer leak into the part_of tree.
|
|
202
|
+
subtree: (
|
|
203
|
+
_: unknown,
|
|
204
|
+
args: { nodeId: string; relation?: string; maxDepth?: number },
|
|
205
|
+
): SubtreeNodeGql[] => {
|
|
206
|
+
const relation = args.relation ?? 'part_of'
|
|
181
207
|
const maxDepth = args.maxDepth ?? 100
|
|
182
208
|
if (maxDepth === 0) return []
|
|
183
209
|
const rows = db.raw
|
|
184
210
|
.query(
|
|
185
|
-
`WITH RECURSIVE tree(id, depth) AS (
|
|
186
|
-
SELECT source_id, 1 FROM edges
|
|
211
|
+
`WITH RECURSIVE tree(id, parent_id, depth) AS (
|
|
212
|
+
SELECT source_id, target_id, 1 FROM edges
|
|
213
|
+
WHERE target_id = ?1 AND relation = ?3
|
|
187
214
|
UNION ALL
|
|
188
|
-
SELECT e.source_id, t.depth + 1 FROM edges e
|
|
189
|
-
|
|
215
|
+
SELECT e.source_id, e.target_id, t.depth + 1 FROM edges e
|
|
216
|
+
JOIN tree t ON e.target_id = t.id
|
|
217
|
+
WHERE t.depth < ?2 AND e.relation = ?3
|
|
190
218
|
)
|
|
191
|
-
SELECT
|
|
219
|
+
SELECT ${prefixedCols()}, t.parent_id AS parent_id, t.depth AS depth, ?3 AS edge_relation
|
|
220
|
+
FROM nodes n JOIN tree t ON n.id = t.id
|
|
221
|
+
ORDER BY t.depth`,
|
|
192
222
|
)
|
|
193
|
-
.all(args.nodeId, maxDepth) as
|
|
194
|
-
return
|
|
223
|
+
.all(args.nodeId, maxDepth, relation) as SubtreeRow[]
|
|
224
|
+
return rows.map((row) => ({
|
|
225
|
+
...rowToNode(row),
|
|
226
|
+
parentId: row.parent_id,
|
|
227
|
+
depth: row.depth,
|
|
228
|
+
relation: row.edge_relation,
|
|
229
|
+
}))
|
|
195
230
|
},
|
|
196
231
|
|
|
197
232
|
// Nodes connected to `nodeId` by `relation`, following edges in the given
|
package/server/src/schema.ts
CHANGED
|
@@ -19,11 +19,29 @@ export const typeDefs = /* GraphQL */ `
|
|
|
19
19
|
createdAt: String!
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
# A node returned from a subtree traversal, annotated with how it was reached:
|
|
23
|
+
# the parent it descends from (parentId), how many edges down the root it sits
|
|
24
|
+
# (depth, root's direct children are depth 1), and the relation of the edge
|
|
25
|
+
# that links it to its parent.
|
|
26
|
+
type SubtreeNode {
|
|
27
|
+
id: String!
|
|
28
|
+
type: String!
|
|
29
|
+
title: String!
|
|
30
|
+
description: String
|
|
31
|
+
status: String!
|
|
32
|
+
metadata: String
|
|
33
|
+
createdAt: String!
|
|
34
|
+
updatedAt: String!
|
|
35
|
+
parentId: String!
|
|
36
|
+
depth: Int!
|
|
37
|
+
relation: String!
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
type Query {
|
|
23
41
|
node(id: String!): Node
|
|
24
42
|
nodes(type: String): [Node!]!
|
|
25
43
|
descendants(nodeId: String!, relation: String, maxDepth: Int): [Node!]!
|
|
26
|
-
subtree(nodeId: String!, maxDepth: Int): [
|
|
44
|
+
subtree(nodeId: String!, relation: String, maxDepth: Int): [SubtreeNode!]!
|
|
27
45
|
edges(nodeId: String!, relation: String!, direction: String): [Node!]!
|
|
28
46
|
readyTasks(projectId: String): [Node!]!
|
|
29
47
|
search(query: String!, type: String, status: String, limit: Int): [Node!]!
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest'
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
describe('tree command', () => {
|
|
4
4
|
test('exports a flat command with id argument and depth option', async () => {
|
|
@@ -6,4 +6,92 @@ describe('tree command', () => {
|
|
|
6
6
|
expect(treeCommand.name()).toBe('tree')
|
|
7
7
|
expect(treeCommand.commands).toHaveLength(0)
|
|
8
8
|
})
|
|
9
|
+
|
|
10
|
+
test('exposes a --relation option defaulting to part_of', async () => {
|
|
11
|
+
const { treeCommand } = await import('./tree.ts')
|
|
12
|
+
const relationOpt = treeCommand.options.find((o) => o.long === '--relation')
|
|
13
|
+
expect(relationOpt).toBeDefined()
|
|
14
|
+
expect(relationOpt?.defaultValue).toBe('part_of')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('action', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetModules()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks()
|
|
24
|
+
vi.unstubAllEnvs()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('sends the SUBTREE op with the relation filter and renders depth/relation', async () => {
|
|
28
|
+
const calls: Array<{ query: string; variables: unknown }> = []
|
|
29
|
+
vi.doMock('../util/client.ts', () => ({
|
|
30
|
+
graphql: vi.fn(async (query: string, variables: unknown) => {
|
|
31
|
+
calls.push({ query, variables })
|
|
32
|
+
return {
|
|
33
|
+
subtree: [
|
|
34
|
+
{
|
|
35
|
+
id: 'feat_1',
|
|
36
|
+
type: 'feature',
|
|
37
|
+
title: 'F1',
|
|
38
|
+
status: 'draft',
|
|
39
|
+
parentId: 'proj_1',
|
|
40
|
+
depth: 1,
|
|
41
|
+
relation: 'part_of',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
}),
|
|
46
|
+
}))
|
|
47
|
+
const outputs: unknown[] = []
|
|
48
|
+
vi.doMock('../util/format.ts', () => ({
|
|
49
|
+
output: vi.fn((data: unknown) => outputs.push(data)),
|
|
50
|
+
outputError: vi.fn(),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const { SUBTREE } = await import('../util/operations.ts')
|
|
54
|
+
const { treeCommand } = await import('./tree.ts')
|
|
55
|
+
await treeCommand.parseAsync(['proj_1'], { from: 'user' })
|
|
56
|
+
|
|
57
|
+
expect(calls).toHaveLength(1)
|
|
58
|
+
expect(calls[0]!.query).toBe(SUBTREE)
|
|
59
|
+
expect(calls[0]!.variables).toMatchObject({
|
|
60
|
+
nodeId: 'proj_1',
|
|
61
|
+
relation: 'part_of',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const rendered = outputs[0] as Array<Record<string, unknown>>
|
|
65
|
+
expect(rendered[0]).toMatchObject({
|
|
66
|
+
id: 'feat_1',
|
|
67
|
+
parentId: 'proj_1',
|
|
68
|
+
depth: 1,
|
|
69
|
+
relation: 'part_of',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('passes an overridden relation through to the op', async () => {
|
|
74
|
+
const calls: Array<{ variables: unknown }> = []
|
|
75
|
+
vi.doMock('../util/client.ts', () => ({
|
|
76
|
+
graphql: vi.fn(async (_query: string, variables: unknown) => {
|
|
77
|
+
calls.push({ variables })
|
|
78
|
+
return { subtree: [] }
|
|
79
|
+
}),
|
|
80
|
+
}))
|
|
81
|
+
vi.doMock('../util/format.ts', () => ({
|
|
82
|
+
output: vi.fn(),
|
|
83
|
+
outputError: vi.fn(),
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
const { treeCommand } = await import('./tree.ts')
|
|
87
|
+
await treeCommand.parseAsync(['task_1', '--relation', 'blocks'], {
|
|
88
|
+
from: 'user',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(calls[0]!.variables).toMatchObject({
|
|
92
|
+
nodeId: 'task_1',
|
|
93
|
+
relation: 'blocks',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
9
97
|
})
|
package/src/commands/tree.ts
CHANGED
|
@@ -7,10 +7,16 @@ export const treeCommand = new Command('tree')
|
|
|
7
7
|
.description('Show subtree from any entity')
|
|
8
8
|
.argument('<id>', 'Root node ID')
|
|
9
9
|
.option('--depth <n>', 'Max depth', '10')
|
|
10
|
+
.option(
|
|
11
|
+
'--relation <relation>',
|
|
12
|
+
'Edge relation to follow (e.g. part_of, blocks)',
|
|
13
|
+
'part_of',
|
|
14
|
+
)
|
|
10
15
|
.action(async (id: string, opts) => {
|
|
11
16
|
try {
|
|
12
17
|
const data = await graphql<{ subtree: unknown[] }>(SUBTREE, {
|
|
13
18
|
nodeId: id,
|
|
19
|
+
relation: opts.relation,
|
|
14
20
|
maxDepth: Number.parseInt(opts.depth, 10),
|
|
15
21
|
})
|
|
16
22
|
output(data.subtree)
|
package/src/util/operations.ts
CHANGED
|
@@ -76,10 +76,11 @@ export const LIST_TASKS = `query ListTasks($nodeId: String!, $relation: String!,
|
|
|
76
76
|
}
|
|
77
77
|
}`
|
|
78
78
|
|
|
79
|
-
/** tree.ts —
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
/** tree.ts — subtree from any root, filtered to one relation (default part_of),
|
|
80
|
+
* each node annotated with parentId/depth/relation. */
|
|
81
|
+
export const SUBTREE = `query Subtree($nodeId: String!, $relation: String, $maxDepth: Int) {
|
|
82
|
+
subtree(nodeId: $nodeId, relation: $relation, maxDepth: $maxDepth) {
|
|
83
|
+
id type title status parentId depth relation
|
|
83
84
|
}
|
|
84
85
|
}`
|
|
85
86
|
|